diff --git a/src/components/RelayViewer.tsx b/src/components/RelayViewer.tsx
index 6e50325..44f1ce6 100644
--- a/src/components/RelayViewer.tsx
+++ b/src/components/RelayViewer.tsx
@@ -3,7 +3,7 @@ import { useRelayInfo } from "../hooks/useRelayInfo";
import { useCopy } from "../hooks/useCopy";
import { Button } from "./ui/button";
import { UserName } from "./nostr/UserName";
-import { NIPBadge } from "./NIPBadge";
+import { RelaySupportedNips } from "./nostr/RelaySupportedNips";
export interface RelayViewerProps {
url: string;
@@ -68,19 +68,8 @@ export function RelayViewer({ url }: RelayViewerProps) {
)}
{/* Supported NIPs */}
- {info?.supported_nips && info.supported_nips.length > 0 && (
-
-
Supported NIPs
-
- {info.supported_nips.map((num: number) => (
-
- ))}
-
-
+ {info?.supported_nips && (
+
)}
);
diff --git a/src/components/nostr/RelayKindsDisplay.tsx b/src/components/nostr/RelayKindsDisplay.tsx
new file mode 100644
index 0000000..8beab46
--- /dev/null
+++ b/src/components/nostr/RelayKindsDisplay.tsx
@@ -0,0 +1,60 @@
+import { Badge } from "@/components/ui/badge";
+import { Label } from "@/components/ui/label";
+import { Filter, XCircle } from "lucide-react";
+
+interface RelayKindsDisplayProps {
+ accepted: number[];
+ rejected: number[];
+}
+
+/**
+ * Relay Kinds Display Component
+ * Shows accepted and rejected event kinds for a relay in a consistent format
+ * Used in both Relay Discovery and Monitor Announcement detail views
+ */
+export function RelayKindsDisplay({
+ accepted,
+ rejected,
+}: RelayKindsDisplayProps) {
+ return (
+ <>
+ {/* Accepted Kinds */}
+ {accepted.length > 0 && (
+
+
+
+ {accepted.map((kind) => (
+
+ {kind}
+
+ ))}
+
+
+ )}
+
+ {/* Rejected Kinds */}
+ {rejected.length > 0 && (
+
+
+
+ {rejected.map((kind) => (
+
+ !{kind}
+
+ ))}
+
+
+ )}
+ >
+ );
+}
diff --git a/src/components/nostr/RelaySupportedNips.tsx b/src/components/nostr/RelaySupportedNips.tsx
new file mode 100644
index 0000000..c0cfe8d
--- /dev/null
+++ b/src/components/nostr/RelaySupportedNips.tsx
@@ -0,0 +1,38 @@
+import { NIPBadge } from "@/components/NIPBadge";
+
+interface RelaySupportedNipsProps {
+ nips: number[];
+ title?: string;
+ showTitle?: boolean;
+}
+
+/**
+ * Relay Supported NIPs Display Component
+ * Shows supported Nostr Implementation Possibilities (NIPs) for a relay
+ * Used in RelayViewer and Relay Discovery detail views
+ * Renders NIP badges with names for better readability
+ */
+export function RelaySupportedNips({
+ nips,
+ title = "Supported NIPs",
+ showTitle = true,
+}: RelaySupportedNipsProps) {
+ if (nips.length === 0) {
+ return null;
+ }
+
+ return (
+
+ {showTitle &&
{title}
}
+
+ {nips.map((nip) => (
+
+ ))}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/MonitorAnnouncementDetailRenderer.tsx b/src/components/nostr/kinds/MonitorAnnouncementDetailRenderer.tsx
new file mode 100644
index 0000000..fb03944
--- /dev/null
+++ b/src/components/nostr/kinds/MonitorAnnouncementDetailRenderer.tsx
@@ -0,0 +1,138 @@
+import { NostrEvent } from "@/types/nostr";
+import { Badge } from "@/components/ui/badge";
+import { Label } from "@/components/ui/label";
+import { UserName } from "../UserName";
+import {
+ getMonitorFrequency,
+ getMonitorTimeouts,
+ getMonitorChecks,
+ getMonitorGeohash,
+ formatFrequency,
+ formatTimeout,
+ getCheckTypeName,
+} from "@/lib/nip66-helpers";
+import { Activity, Clock, MapPin, Timer } from "lucide-react";
+
+/**
+ * Monitor Announcement Detail Renderer - NIP-66 Relay Monitor Announcement (Detail View)
+ * Kind 10166 - Shows comprehensive monitor configuration including frequency, timeouts, and checks
+ */
+export function MonitorAnnouncementDetailRenderer({
+ event,
+}: {
+ event: NostrEvent;
+}) {
+ const frequency = getMonitorFrequency(event);
+ const timeouts = getMonitorTimeouts(event);
+ const checks = getMonitorChecks(event);
+ const geohash = getMonitorGeohash(event);
+
+ return (
+
+ {/* Header: Monitor Identity */}
+
+
Relay Monitor
+
+ Operated by
+
+
+
+
+ {/* Operational Parameters */}
+
+ {/* Monitoring Frequency */}
+ {frequency && !isNaN(frequency) && (
+
+
+
+
+ {formatFrequency(frequency)}
+
+
+ ({frequency} seconds)
+
+
+
+ )}
+
+ {/* Check Types Performed */}
+ {checks.length > 0 && (
+
+
+
+ {checks.map((check) => (
+
+ {getCheckTypeName(check)}
+
+ ))}
+
+
+ )}
+
+ {/* Timeout Configurations */}
+ {Object.keys(timeouts).length > 0 && (
+
+
+
+ {Object.entries(timeouts).map(([checkType, timeout]) => (
+
+
+ {getCheckTypeName(checkType)}
+
+
+ {formatTimeout(timeout)}
+
+
+ ))}
+
+
+ )}
+
+ {/* Geographic Location */}
+ {geohash && (
+
+
+
+
+ {geohash}
+
+
+ )}
+
+
+ {/* Monitor Description */}
+ {event.content && event.content.trim() !== "" && (
+
+
+
{event.content}
+
+ )}
+
+ {/* Empty State */}
+ {!frequency &&
+ checks.length === 0 &&
+ Object.keys(timeouts).length === 0 &&
+ !geohash &&
+ (!event.content || event.content.trim() === "") && (
+
+ No monitoring configuration specified
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/MonitorAnnouncementRenderer.tsx b/src/components/nostr/kinds/MonitorAnnouncementRenderer.tsx
new file mode 100644
index 0000000..bc78a49
--- /dev/null
+++ b/src/components/nostr/kinds/MonitorAnnouncementRenderer.tsx
@@ -0,0 +1,61 @@
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { Label } from "@/components/ui/label";
+import {
+ getMonitorFrequency,
+ getMonitorChecks,
+ formatFrequency,
+ getCheckTypeName,
+} from "@/lib/nip66-helpers";
+import { Clock } from "lucide-react";
+
+/**
+ * Monitor Announcement Renderer - NIP-66 Relay Monitor Announcement (Feed View)
+ * Kind 10166 - Displays monitor announcement with frequency and check types
+ */
+export function MonitorAnnouncementRenderer({ event }: BaseEventProps) {
+ const frequency = getMonitorFrequency(event);
+ const checks = getMonitorChecks(event);
+
+ return (
+
+
+ {/* Clickable Title */}
+
+ Relay Monitor
+
+
+ {/* Monitoring Frequency */}
+ {frequency && !isNaN(frequency) && (
+
+
+
+ Checks every{" "}
+ {formatFrequency(frequency)}
+
+
+ )}
+
+ {/* Check Types */}
+ {checks.length > 0 && (
+
+ {checks.map((check) => (
+
+ ))}
+
+ )}
+
+ {!frequency && checks.length === 0 && (
+
+ No monitoring configuration specified
+
+ )}
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/RelayDiscoveryDetailRenderer.tsx b/src/components/nostr/kinds/RelayDiscoveryDetailRenderer.tsx
new file mode 100644
index 0000000..6e1eadd
--- /dev/null
+++ b/src/components/nostr/kinds/RelayDiscoveryDetailRenderer.tsx
@@ -0,0 +1,275 @@
+import { NostrEvent } from "@/types/nostr";
+import { RelayLink } from "../RelayLink";
+import { Badge } from "@/components/ui/badge";
+import { Label } from "@/components/ui/label";
+import { JsonViewer } from "@/components/JsonViewer";
+import { UserName } from "../UserName";
+import { RelayKindsDisplay } from "../RelayKindsDisplay";
+import { RelaySupportedNips } from "../RelaySupportedNips";
+import {
+ getRelayUrl,
+ getRttMetrics,
+ getNetworkType,
+ getRelayType,
+ getSupportedNips,
+ getRelayRequirements,
+ getRelayTopics,
+ getRelayKinds,
+ getRelayGeohash,
+ parseNip11Document,
+ calculateRelayHealth,
+} from "@/lib/nip66-helpers";
+import {
+ Activity,
+ CircleDot,
+ Globe,
+ Lock,
+ CreditCard,
+ Hammer,
+ MapPin,
+ Shield,
+ Tag,
+ Clock,
+ CheckCircle,
+ XCircle,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+import { formatTimestamp } from "@/hooks/useLocale";
+import { useState } from "react";
+
+/**
+ * Relay Discovery Detail Renderer - NIP-66 Relay Discovery (Detail View)
+ * Kind 30166 - Shows comprehensive relay information with all metrics and capabilities
+ */
+export function RelayDiscoveryDetailRenderer({ event }: { event: NostrEvent }) {
+ const [showNip11, setShowNip11] = useState(false);
+
+ const relayUrl = getRelayUrl(event);
+ const rtt = getRttMetrics(event);
+ const networkType = getNetworkType(event);
+ const relayType = getRelayType(event);
+ const nips = getSupportedNips(event);
+ const requirements = getRelayRequirements(event);
+ const topics = getRelayTopics(event);
+ const kinds = getRelayKinds(event);
+ const geohash = getRelayGeohash(event);
+ const nip11 = parseNip11Document(event);
+ const health = calculateRelayHealth(event);
+
+ if (!relayUrl) {
+ return (
+
+ Invalid relay discovery event (missing relay URL)
+
+ );
+ }
+
+ // Calculate health color based on score
+ const healthColor =
+ health >= 80
+ ? "text-green-600"
+ : health >= 50
+ ? "text-yellow-600"
+ : "text-red-600";
+
+ return (
+
+ {/* Header: Relay URL and Health */}
+
+
+ {/* Performance Metrics Section */}
+ {(rtt.open !== undefined ||
+ rtt.read !== undefined ||
+ rtt.write !== undefined) && (
+
+
+
+ {rtt.open !== undefined && !isNaN(rtt.open) && (
+
+
+ Connection
+
+ {rtt.open}ms
+
+ )}
+ {rtt.read !== undefined && !isNaN(rtt.read) && (
+
+ Read
+ {rtt.read}ms
+
+ )}
+ {rtt.write !== undefined && !isNaN(rtt.write) && (
+
+ Write
+ {rtt.write}ms
+
+ )}
+
+
+ )}
+
+ {/* Relay Characteristics */}
+ {(networkType || relayType || geohash) && (
+
+
+
+ {networkType && (
+
+ {networkType === "tor" && }
+ {(networkType === "i2p" || networkType === "clearnet") && (
+
+ )}
+ {networkType}
+
+ )}
+ {relayType && (
+
+ {relayType}
+
+ )}
+ {geohash && (
+
+
+ {geohash}
+
+ )}
+
+
+ )}
+
+ {/* Requirements Section */}
+ {Object.keys(requirements).length > 0 && (
+
+
+
+ {requirements.auth !== undefined && (
+
+ {requirements.auth ? (
+
+ ) : (
+
+ )}
+
+ Authentication{" "}
+ {requirements.auth ? "required" : "not required"}
+
+
+ )}
+ {requirements.payment !== undefined && (
+
+ {requirements.payment ? (
+
+ ) : (
+
+ )}
+
+ Payment {requirements.payment ? "required" : "not required"}
+
+
+ )}
+ {requirements.writes !== undefined && (
+
+ {requirements.writes ? (
+
+ ) : (
+
+ )}
+
+ {requirements.writes
+ ? "Write access enabled"
+ : "Read-only relay"}
+
+
+ )}
+ {requirements.pow !== undefined && (
+
+ {requirements.pow ? (
+
+ ) : (
+
+ )}
+
+ Proof of work {requirements.pow ? "required" : "not required"}
+
+
+ )}
+
+
+ )}
+
+ {/* Supported NIPs */}
+ {nips.length > 0 && (
+
+ )}
+
+ {/* Relay Kinds */}
+
+
+ {/* Topics */}
+ {topics.length > 0 && (
+
+
+
+ {topics.map((topic, index) => (
+
+ {topic}
+
+ ))}
+
+
+ )}
+
+ {/* NIP-11 Document */}
+ {nip11 && (
+
+
+
+ )}
+
+ {/* Monitor Attribution */}
+
+
+
+
+ Monitored {formatTimestamp(event.created_at)} by{" "}
+
+
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/RelayDiscoveryRenderer.tsx b/src/components/nostr/kinds/RelayDiscoveryRenderer.tsx
new file mode 100644
index 0000000..154fe9b
--- /dev/null
+++ b/src/components/nostr/kinds/RelayDiscoveryRenderer.tsx
@@ -0,0 +1,147 @@
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { Badge } from "@/components/ui/badge";
+import {
+ getRelayUrl,
+ getRttMetrics,
+ getNetworkType,
+ getRelayType,
+ getRelayRequirements,
+ calculateRelayHealth,
+} from "@/lib/nip66-helpers";
+import {
+ Activity,
+ CircleDot,
+ Globe,
+ Lock,
+ CreditCard,
+ Hammer,
+} from "lucide-react";
+import { cn } from "@/lib/utils";
+
+/**
+ * Relay Discovery Renderer - NIP-66 Relay Discovery (Feed View)
+ * Kind 30166 - Displays relay information with health metrics, network type, and capabilities
+ */
+export function RelayDiscoveryRenderer({ event }: BaseEventProps) {
+ const relayUrl = getRelayUrl(event);
+ const rtt = getRttMetrics(event);
+ const networkType = getNetworkType(event);
+ const relayType = getRelayType(event);
+ const requirements = getRelayRequirements(event);
+ const health = calculateRelayHealth(event);
+
+ if (!relayUrl) {
+ return (
+
+
+ Invalid relay discovery event (missing relay URL)
+
+
+ );
+ }
+
+ // Calculate health color based on score
+ const healthColor =
+ health >= 80
+ ? "text-green-600"
+ : health >= 50
+ ? "text-yellow-600"
+ : "text-red-600";
+
+ // Format RTT for display (average of available metrics)
+ const avgRtt = [rtt.open, rtt.read, rtt.write]
+ .filter((v): v is number => v !== undefined && !isNaN(v))
+ .reduce((sum, v, _, arr) => sum + v / arr.length, 0);
+
+ return (
+
+
+ {/* Clickable Title and Health Score */}
+
+
+ {/* Badges: Network Type, Relay Type, RTT, Requirements */}
+
+ {/* Network Type Badge */}
+ {networkType && (
+
+ {networkType === "tor" && }
+ {networkType === "i2p" && }
+ {networkType === "clearnet" && }
+ {networkType}
+
+ )}
+
+ {/* Relay Type Badge */}
+ {relayType && (
+
+ {relayType}
+
+ )}
+
+ {/* RTT Badge */}
+ {avgRtt > 0 && (
+
+
+ {Math.round(avgRtt)}ms
+
+ )}
+
+ {/* Requirements Badges */}
+ {requirements.auth && (
+
+
+ Auth
+
+ )}
+ {requirements.payment && (
+
+
+ Paid
+
+ )}
+ {requirements.writes === false && (
+
+ Read-only
+
+ )}
+ {requirements.pow && (
+
+
+ PoW
+
+ )}
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index 1ec856d..fc5058c 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -148,6 +148,10 @@ import { BadgeAwardRenderer } from "./BadgeAwardRenderer";
import { BadgeAwardDetailRenderer } from "./BadgeAwardDetailRenderer";
import { ProfileBadgesRenderer } from "./ProfileBadgesRenderer";
import { ProfileBadgesDetailRenderer } from "./ProfileBadgesDetailRenderer";
+import { MonitorAnnouncementRenderer } from "./MonitorAnnouncementRenderer";
+import { MonitorAnnouncementDetailRenderer } from "./MonitorAnnouncementDetailRenderer";
+import { RelayDiscoveryRenderer } from "./RelayDiscoveryRenderer";
+import { RelayDiscoveryDetailRenderer } from "./RelayDiscoveryDetailRenderer";
import { GoalRenderer } from "./GoalRenderer";
import { GoalDetailRenderer } from "./GoalDetailRenderer";
@@ -201,6 +205,7 @@ const kindRenderers: Record> = {
10063: BlossomServerListRenderer, // Blossom User Server List (BUD-03)
10101: WikiAuthorsRenderer, // Good Wiki Authors (NIP-51)
10102: WikiRelaysRenderer, // Good Wiki Relays (NIP-51)
+ 10166: MonitorAnnouncementRenderer, // Relay Monitor Announcement (NIP-66)
10317: Kind10317Renderer, // User Grasp List (NIP-34)
13534: RelayMembersRenderer, // Relay Members (NIP-43)
30000: FollowSetRenderer, // Follow Sets (NIP-51)
@@ -216,6 +221,7 @@ const kindRenderers: Record> = {
30023: Kind30023Renderer, // Long-form Article
30030: EmojiSetRenderer, // Emoji Sets (NIP-30)
30063: ZapstoreReleaseRenderer, // Zapstore App Release
+ 30166: RelayDiscoveryRenderer, // Relay Discovery (NIP-66)
30267: ZapstoreAppSetRenderer, // Zapstore App Collection
30311: LiveActivityRenderer, // Live Streaming Event (NIP-53)
34235: Kind21Renderer, // Horizontal Video (NIP-71 legacy)
@@ -300,6 +306,7 @@ const detailRenderers: Record<
10063: BlossomServerListDetailRenderer, // Blossom User Server List Detail (BUD-03)
10101: WikiAuthorsDetailRenderer, // Good Wiki Authors Detail (NIP-51)
10102: WikiRelaysDetailRenderer, // Good Wiki Relays Detail (NIP-51)
+ 10166: MonitorAnnouncementDetailRenderer, // Relay Monitor Announcement Detail (NIP-66)
10317: Kind10317DetailRenderer, // User Grasp List Detail (NIP-34)
13534: RelayMembersDetailRenderer, // Relay Members Detail (NIP-43)
30000: FollowSetDetailRenderer, // Follow Sets Detail (NIP-51)
@@ -314,6 +321,7 @@ const detailRenderers: Record<
30023: Kind30023DetailRenderer, // Long-form Article Detail
30030: EmojiSetDetailRenderer, // Emoji Sets Detail (NIP-30)
30063: ZapstoreReleaseDetailRenderer, // Zapstore App Release Detail
+ 30166: RelayDiscoveryDetailRenderer, // Relay Discovery Detail (NIP-66)
30267: ZapstoreAppSetDetailRenderer, // Zapstore App Collection Detail
30311: LiveActivityDetailRenderer, // Live Streaming Event Detail (NIP-53)
30617: RepositoryDetailRenderer, // Repository Detail (NIP-34)
diff --git a/src/lib/nip66-helpers.test.ts b/src/lib/nip66-helpers.test.ts
new file mode 100644
index 0000000..88beecd
--- /dev/null
+++ b/src/lib/nip66-helpers.test.ts
@@ -0,0 +1,667 @@
+import { describe, it, expect } from "vitest";
+import {
+ getRelayUrl,
+ getRttMetrics,
+ getNetworkType,
+ getRelayType,
+ getSupportedNips,
+ getRelayRequirements,
+ getRelayTopics,
+ getRelayKinds,
+ getRelayGeohash,
+ parseNip11Document,
+ calculateRelayHealth,
+ getMonitorFrequency,
+ getMonitorTimeouts,
+ getMonitorChecks,
+ getMonitorGeohash,
+ formatFrequency,
+ formatTimeout,
+ getCheckTypeName,
+} from "./nip66-helpers";
+import { NostrEvent } from "@/types/nostr";
+
+// Helper to create a minimal kind 30166 event (Relay Discovery)
+function createRelayDiscoveryEvent(
+ overrides?: Partial,
+): NostrEvent {
+ return {
+ id: "test-id",
+ pubkey: "test-pubkey",
+ created_at: Math.floor(Date.now() / 1000), // Current time by default
+ kind: 30166,
+ tags: [],
+ content: "",
+ sig: "test-sig",
+ ...overrides,
+ };
+}
+
+// Helper to create a minimal kind 10166 event (Monitor Announcement)
+function createMonitorEvent(overrides?: Partial): NostrEvent {
+ return {
+ id: "test-id",
+ pubkey: "test-pubkey",
+ created_at: Math.floor(Date.now() / 1000),
+ kind: 10166,
+ tags: [],
+ content: "",
+ sig: "test-sig",
+ ...overrides,
+ };
+}
+
+describe("Kind 30166 (Relay Discovery) Helpers", () => {
+ describe("getRelayUrl", () => {
+ it("should extract relay URL from d tag", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "wss://relay.example.com"]],
+ });
+ expect(getRelayUrl(event)).toBe("wss://relay.example.com");
+ });
+
+ it("should handle normalized URL without wss://", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ });
+ expect(getRelayUrl(event)).toBe("relay.example.com");
+ });
+
+ it("should return undefined if no d tag", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [],
+ });
+ expect(getRelayUrl(event)).toBeUndefined();
+ });
+ });
+
+ describe("getRttMetrics", () => {
+ it("should parse all RTT values", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["rtt-open", "150"],
+ ["rtt-read", "200"],
+ ["rtt-write", "250"],
+ ],
+ });
+ const rtt = getRttMetrics(event);
+ expect(rtt.open).toBe(150);
+ expect(rtt.read).toBe(200);
+ expect(rtt.write).toBe(250);
+ });
+
+ it("should handle missing RTT values", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["rtt-open", "100"],
+ ],
+ });
+ const rtt = getRttMetrics(event);
+ expect(rtt.open).toBe(100);
+ expect(rtt.read).toBeUndefined();
+ expect(rtt.write).toBeUndefined();
+ });
+
+ it("should return all undefined if no RTT tags", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ });
+ const rtt = getRttMetrics(event);
+ expect(rtt.open).toBeUndefined();
+ expect(rtt.read).toBeUndefined();
+ expect(rtt.write).toBeUndefined();
+ });
+
+ it("should handle non-numeric RTT values", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["rtt-open", "invalid"],
+ ],
+ });
+ const rtt = getRttMetrics(event);
+ expect(rtt.open).toBeNaN();
+ });
+ });
+
+ describe("getNetworkType", () => {
+ it("should extract clearnet network type", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["n", "clearnet"],
+ ],
+ });
+ expect(getNetworkType(event)).toBe("clearnet");
+ });
+
+ it("should extract tor network type", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.onion"],
+ ["n", "tor"],
+ ],
+ });
+ expect(getNetworkType(event)).toBe("tor");
+ });
+
+ it("should return undefined if no network type tag", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ });
+ expect(getNetworkType(event)).toBeUndefined();
+ });
+ });
+
+ describe("getRelayType", () => {
+ it("should extract relay type", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["T", "Public"],
+ ],
+ });
+ expect(getRelayType(event)).toBe("Public");
+ });
+
+ it("should return undefined if no type tag", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ });
+ expect(getRelayType(event)).toBeUndefined();
+ });
+ });
+
+ describe("getSupportedNips", () => {
+ it("should extract and sort NIP numbers", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["N", "11"],
+ ["N", "1"],
+ ["N", "65"],
+ ["N", "42"],
+ ],
+ });
+ expect(getSupportedNips(event)).toEqual([1, 11, 42, 65]);
+ });
+
+ it("should deduplicate NIPs", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["N", "1"],
+ ["N", "11"],
+ ["N", "1"],
+ ],
+ });
+ expect(getSupportedNips(event)).toEqual([1, 11]);
+ });
+
+ it("should filter out invalid NIP numbers", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["N", "1"],
+ ["N", "invalid"],
+ ["N", "11"],
+ ],
+ });
+ expect(getSupportedNips(event)).toEqual([1, 11]);
+ });
+
+ it("should return empty array if no NIP tags", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ });
+ expect(getSupportedNips(event)).toEqual([]);
+ });
+ });
+
+ describe("getRelayRequirements", () => {
+ it("should parse positive requirements", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["R", "auth"],
+ ["R", "payment"],
+ ],
+ });
+ const reqs = getRelayRequirements(event);
+ expect(reqs.auth).toBe(true);
+ expect(reqs.payment).toBe(true);
+ });
+
+ it("should parse negative requirements with ! prefix", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["R", "!auth"],
+ ["R", "!payment"],
+ ],
+ });
+ const reqs = getRelayRequirements(event);
+ expect(reqs.auth).toBe(false);
+ expect(reqs.payment).toBe(false);
+ });
+
+ it("should handle mixed positive and negative requirements", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["R", "auth"],
+ ["R", "!payment"],
+ ["R", "writes"],
+ ["R", "!pow"],
+ ],
+ });
+ const reqs = getRelayRequirements(event);
+ expect(reqs.auth).toBe(true);
+ expect(reqs.payment).toBe(false);
+ expect(reqs.writes).toBe(true);
+ expect(reqs.pow).toBe(false);
+ });
+
+ it("should return empty object if no requirement tags", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ });
+ expect(getRelayRequirements(event)).toEqual({});
+ });
+
+ it("should ignore unknown requirements", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["R", "auth"],
+ ["R", "unknown"],
+ ],
+ });
+ const reqs = getRelayRequirements(event);
+ expect(reqs.auth).toBe(true);
+ expect(Object.keys(reqs)).toHaveLength(1);
+ });
+ });
+
+ describe("getRelayTopics", () => {
+ it("should extract all topics", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["t", "bitcoin"],
+ ["t", "nostr"],
+ ["t", "general"],
+ ],
+ });
+ expect(getRelayTopics(event)).toEqual(["bitcoin", "nostr", "general"]);
+ });
+
+ it("should return empty array if no topics", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ });
+ expect(getRelayTopics(event)).toEqual([]);
+ });
+ });
+
+ describe("getRelayKinds", () => {
+ it("should separate accepted and rejected kinds", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["k", "1"],
+ ["k", "3"],
+ ["k", "!7"],
+ ["k", "!1984"],
+ ],
+ });
+ const kinds = getRelayKinds(event);
+ expect(kinds.accepted).toEqual([1, 3]);
+ expect(kinds.rejected).toEqual([7, 1984]);
+ });
+
+ it("should deduplicate kinds", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["k", "1"],
+ ["k", "1"],
+ ["k", "!7"],
+ ["k", "!7"],
+ ],
+ });
+ const kinds = getRelayKinds(event);
+ expect(kinds.accepted).toEqual([1]);
+ expect(kinds.rejected).toEqual([7]);
+ });
+
+ it("should return empty arrays if no kind tags", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ });
+ const kinds = getRelayKinds(event);
+ expect(kinds.accepted).toEqual([]);
+ expect(kinds.rejected).toEqual([]);
+ });
+
+ it("should filter out invalid kind numbers", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["k", "1"],
+ ["k", "invalid"],
+ ["k", "!7"],
+ ],
+ });
+ const kinds = getRelayKinds(event);
+ expect(kinds.accepted).toEqual([1]);
+ expect(kinds.rejected).toEqual([7]);
+ });
+ });
+
+ describe("getRelayGeohash", () => {
+ it("should extract geohash", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["g", "u4pruydqqvj"],
+ ],
+ });
+ expect(getRelayGeohash(event)).toBe("u4pruydqqvj");
+ });
+
+ it("should return undefined if no geohash tag", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ });
+ expect(getRelayGeohash(event)).toBeUndefined();
+ });
+ });
+
+ describe("parseNip11Document", () => {
+ it("should parse valid JSON content", () => {
+ const nip11 = {
+ name: "Test Relay",
+ description: "A test relay",
+ supported_nips: [1, 11, 42],
+ };
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ content: JSON.stringify(nip11),
+ });
+ expect(parseNip11Document(event)).toEqual(nip11);
+ });
+
+ it("should return undefined for empty content", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ content: "",
+ });
+ expect(parseNip11Document(event)).toBeUndefined();
+ });
+
+ it("should return undefined for invalid JSON", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ content: "not json",
+ });
+ expect(parseNip11Document(event)).toBeUndefined();
+ });
+ });
+
+ describe("calculateRelayHealth", () => {
+ it("should return 100 for recent event with low RTT", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["rtt-open", "50"],
+ ["rtt-read", "60"],
+ ["rtt-write", "70"],
+ ],
+ created_at: Math.floor(Date.now() / 1000), // Now
+ });
+ expect(calculateRelayHealth(event)).toBe(100);
+ });
+
+ it("should penalize high RTT", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["rtt-open", "1500"],
+ ["rtt-read", "1500"],
+ ["rtt-write", "1500"],
+ ],
+ created_at: Math.floor(Date.now() / 1000), // Now
+ });
+ const health = calculateRelayHealth(event);
+ expect(health).toBeLessThan(100);
+ expect(health).toBeGreaterThanOrEqual(0);
+ });
+
+ it("should penalize old events", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["rtt-open", "50"],
+ ["rtt-read", "60"],
+ ["rtt-write", "70"],
+ ],
+ created_at: Math.floor(Date.now() / 1000) - 10 * 24 * 60 * 60, // 10 days ago
+ });
+ const health = calculateRelayHealth(event);
+ expect(health).toBeLessThan(100);
+ expect(health).toBeGreaterThanOrEqual(0);
+ });
+
+ it("should handle events with no RTT data", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [["d", "relay.example.com"]],
+ created_at: Math.floor(Date.now() / 1000), // Now
+ });
+ const health = calculateRelayHealth(event);
+ expect(health).toBe(100); // No RTT penalty
+ });
+
+ it("should never return negative health", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["rtt-open", "5000"],
+ ["rtt-read", "5000"],
+ ["rtt-write", "5000"],
+ ],
+ created_at: Math.floor(Date.now() / 1000) - 30 * 24 * 60 * 60, // 30 days ago
+ });
+ const health = calculateRelayHealth(event);
+ expect(health).toBeGreaterThanOrEqual(0);
+ });
+
+ it("should never return health above 100", () => {
+ const event = createRelayDiscoveryEvent({
+ tags: [
+ ["d", "relay.example.com"],
+ ["rtt-open", "10"],
+ ["rtt-read", "10"],
+ ["rtt-write", "10"],
+ ],
+ created_at: Math.floor(Date.now() / 1000), // Now
+ });
+ const health = calculateRelayHealth(event);
+ expect(health).toBeLessThanOrEqual(100);
+ });
+ });
+});
+
+describe("Kind 10166 (Monitor Announcement) Helpers", () => {
+ describe("getMonitorFrequency", () => {
+ it("should extract frequency in seconds", () => {
+ const event = createMonitorEvent({
+ tags: [["frequency", "300"]],
+ });
+ expect(getMonitorFrequency(event)).toBe(300);
+ });
+
+ it("should return undefined if no frequency tag", () => {
+ const event = createMonitorEvent({
+ tags: [],
+ });
+ expect(getMonitorFrequency(event)).toBeUndefined();
+ });
+
+ it("should handle invalid frequency value", () => {
+ const event = createMonitorEvent({
+ tags: [["frequency", "invalid"]],
+ });
+ expect(getMonitorFrequency(event)).toBeNaN();
+ });
+ });
+
+ describe("getMonitorTimeouts", () => {
+ it("should extract timeout configurations", () => {
+ const event = createMonitorEvent({
+ tags: [
+ ["timeout", "open", "1000"],
+ ["timeout", "read", "2000"],
+ ["timeout", "write", "3000"],
+ ],
+ });
+ const timeouts = getMonitorTimeouts(event);
+ expect(timeouts.open).toBe(1000);
+ expect(timeouts.read).toBe(2000);
+ expect(timeouts.write).toBe(3000);
+ });
+
+ it("should return empty object if no timeout tags", () => {
+ const event = createMonitorEvent({
+ tags: [],
+ });
+ expect(getMonitorTimeouts(event)).toEqual({});
+ });
+
+ it("should ignore malformed timeout tags", () => {
+ const event = createMonitorEvent({
+ tags: [
+ ["timeout", "open", "1000"],
+ ["timeout", "invalid"],
+ ["timeout", "read"],
+ ],
+ });
+ const timeouts = getMonitorTimeouts(event);
+ expect(timeouts.open).toBe(1000);
+ expect(Object.keys(timeouts)).toHaveLength(1);
+ });
+ });
+
+ describe("getMonitorChecks", () => {
+ it("should extract all check types", () => {
+ const event = createMonitorEvent({
+ tags: [
+ ["c", "open"],
+ ["c", "read"],
+ ["c", "write"],
+ ["c", "auth"],
+ ],
+ });
+ expect(getMonitorChecks(event)).toEqual([
+ "open",
+ "read",
+ "write",
+ "auth",
+ ]);
+ });
+
+ it("should deduplicate check types", () => {
+ const event = createMonitorEvent({
+ tags: [
+ ["c", "open"],
+ ["c", "read"],
+ ["c", "open"],
+ ],
+ });
+ expect(getMonitorChecks(event)).toEqual(["open", "read"]);
+ });
+
+ it("should return empty array if no check tags", () => {
+ const event = createMonitorEvent({
+ tags: [],
+ });
+ expect(getMonitorChecks(event)).toEqual([]);
+ });
+ });
+
+ describe("getMonitorGeohash", () => {
+ it("should extract geohash", () => {
+ const event = createMonitorEvent({
+ tags: [["g", "u4pruydqqvj"]],
+ });
+ expect(getMonitorGeohash(event)).toBe("u4pruydqqvj");
+ });
+
+ it("should return undefined if no geohash tag", () => {
+ const event = createMonitorEvent({
+ tags: [],
+ });
+ expect(getMonitorGeohash(event)).toBeUndefined();
+ });
+ });
+});
+
+describe("Formatting Utilities", () => {
+ describe("formatFrequency", () => {
+ it("should format seconds", () => {
+ expect(formatFrequency(1)).toBe("1 second");
+ expect(formatFrequency(30)).toBe("30 seconds");
+ });
+
+ it("should format minutes", () => {
+ expect(formatFrequency(60)).toBe("1 minute");
+ expect(formatFrequency(300)).toBe("5 minutes");
+ });
+
+ it("should format hours", () => {
+ expect(formatFrequency(3600)).toBe("1 hour");
+ expect(formatFrequency(7200)).toBe("2 hours");
+ });
+
+ it("should format days", () => {
+ expect(formatFrequency(86400)).toBe("1 day");
+ expect(formatFrequency(172800)).toBe("2 days");
+ });
+ });
+
+ describe("formatTimeout", () => {
+ it("should format milliseconds", () => {
+ expect(formatTimeout(500)).toBe("500ms");
+ expect(formatTimeout(999)).toBe("999ms");
+ });
+
+ it("should format seconds", () => {
+ expect(formatTimeout(1000)).toBe("1s");
+ expect(formatTimeout(2500)).toBe("2.5s");
+ });
+
+ it("should format minutes", () => {
+ expect(formatTimeout(60000)).toBe("1m");
+ expect(formatTimeout(120000)).toBe("2m");
+ });
+ });
+
+ describe("getCheckTypeName", () => {
+ it("should return human-readable names for known check types", () => {
+ expect(getCheckTypeName("open")).toBe("Connection");
+ expect(getCheckTypeName("read")).toBe("Read");
+ expect(getCheckTypeName("write")).toBe("Write");
+ expect(getCheckTypeName("auth")).toBe("Authentication");
+ expect(getCheckTypeName("nip11")).toBe("NIP-11 Info");
+ expect(getCheckTypeName("dns")).toBe("DNS");
+ expect(getCheckTypeName("geo")).toBe("Geolocation");
+ });
+
+ it("should return input for unknown check types", () => {
+ expect(getCheckTypeName("unknown")).toBe("unknown");
+ expect(getCheckTypeName("custom-check")).toBe("custom-check");
+ });
+ });
+});
diff --git a/src/lib/nip66-helpers.ts b/src/lib/nip66-helpers.ts
new file mode 100644
index 0000000..fc3231e
--- /dev/null
+++ b/src/lib/nip66-helpers.ts
@@ -0,0 +1,352 @@
+import type { NostrEvent } from "@/types/nostr";
+import { getTagValue } from "applesauce-core/helpers";
+
+/**
+ * NIP-66 Helper Functions
+ * Utility functions for parsing NIP-66 relay discovery and monitoring events
+ */
+
+// ============================================================================
+// Relay Discovery Event Helpers (Kind 30166)
+// ============================================================================
+
+/**
+ * Get relay URL from d tag (may be normalized or hex-encoded pubkey)
+ * @param event Relay discovery event (kind 30166)
+ * @returns Relay URL or undefined
+ */
+export function getRelayUrl(event: NostrEvent): string | undefined {
+ return getTagValue(event, "d");
+}
+
+/**
+ * Get RTT (round-trip time) metrics in milliseconds
+ * @param event Relay discovery event (kind 30166)
+ * @returns Object with open, read, write RTT values
+ */
+export function getRttMetrics(event: NostrEvent): {
+ open?: number;
+ read?: number;
+ write?: number;
+} {
+ const rttOpen = getTagValue(event, "rtt-open");
+ const rttRead = getTagValue(event, "rtt-read");
+ const rttWrite = getTagValue(event, "rtt-write");
+
+ return {
+ open: rttOpen ? parseInt(rttOpen, 10) : undefined,
+ read: rttRead ? parseInt(rttRead, 10) : undefined,
+ write: rttWrite ? parseInt(rttWrite, 10) : undefined,
+ };
+}
+
+/**
+ * Get network type (clearnet, tor, i2p, loki)
+ * @param event Relay discovery event (kind 30166)
+ * @returns Network type or undefined
+ */
+export function getNetworkType(event: NostrEvent): string | undefined {
+ return getTagValue(event, "n");
+}
+
+/**
+ * Get relay type (PascalCase string like "Read", "Write", "Public")
+ * @param event Relay discovery event (kind 30166)
+ * @returns Relay type or undefined
+ */
+export function getRelayType(event: NostrEvent): string | undefined {
+ return getTagValue(event, "T");
+}
+
+/**
+ * Get array of supported NIP numbers
+ * @param event Relay discovery event (kind 30166)
+ * @returns Array of NIP numbers
+ */
+export function getSupportedNips(event: NostrEvent): number[] {
+ const nips = event.tags
+ .filter((t) => t[0] === "N")
+ .map((t) => parseInt(t[1], 10))
+ .filter((n) => !isNaN(n));
+
+ // Return unique sorted NIPs
+ return Array.from(new Set(nips)).sort((a, b) => a - b);
+}
+
+/**
+ * Get relay requirements (auth, writes, pow, payment)
+ * Returns object with boolean values for each requirement
+ * NIP-66 uses '!' prefix for false/negative requirements
+ * @param event Relay discovery event (kind 30166)
+ * @returns Object with requirement flags
+ */
+export function getRelayRequirements(event: NostrEvent): {
+ auth?: boolean;
+ writes?: boolean;
+ pow?: boolean;
+ payment?: boolean;
+} {
+ const requirements: {
+ auth?: boolean;
+ writes?: boolean;
+ pow?: boolean;
+ payment?: boolean;
+ } = {};
+
+ const reqTags = event.tags.filter((t) => t[0] === "R");
+
+ for (const tag of reqTags) {
+ const value = tag[1];
+ if (!value) continue;
+
+ // Check for ! prefix (negative/false)
+ const isNegative = value.startsWith("!");
+ const requirementKey = isNegative ? value.slice(1) : value;
+ const requirementValue = !isNegative;
+
+ if (requirementKey === "auth") {
+ requirements.auth = requirementValue;
+ } else if (requirementKey === "writes") {
+ requirements.writes = requirementValue;
+ } else if (requirementKey === "pow") {
+ requirements.pow = requirementValue;
+ } else if (requirementKey === "payment") {
+ requirements.payment = requirementValue;
+ }
+ }
+
+ return requirements;
+}
+
+/**
+ * Get array of topics
+ * @param event Relay discovery event (kind 30166)
+ * @returns Array of topic strings
+ */
+export function getRelayTopics(event: NostrEvent): string[] {
+ return event.tags.filter((t) => t[0] === "t").map((t) => t[1]);
+}
+
+/**
+ * Get accepted and rejected kinds
+ * Returns separate arrays for accepted and rejected kinds
+ * NIP-66 uses '!' prefix for rejected kinds
+ * @param event Relay discovery event (kind 30166)
+ * @returns Object with accepted and rejected kind arrays
+ */
+export function getRelayKinds(event: NostrEvent): {
+ accepted: number[];
+ rejected: number[];
+} {
+ const accepted: number[] = [];
+ const rejected: number[] = [];
+
+ const kindTags = event.tags.filter((t) => t[0] === "k");
+
+ for (const tag of kindTags) {
+ const value = tag[1];
+ if (!value) continue;
+
+ // Check for ! prefix (rejected)
+ const isRejected = value.startsWith("!");
+ const kindStr = isRejected ? value.slice(1) : value;
+ const kindNum = parseInt(kindStr, 10);
+
+ if (!isNaN(kindNum)) {
+ if (isRejected) {
+ rejected.push(kindNum);
+ } else {
+ accepted.push(kindNum);
+ }
+ }
+ }
+
+ return {
+ accepted: Array.from(new Set(accepted)).sort((a, b) => a - b),
+ rejected: Array.from(new Set(rejected)).sort((a, b) => a - b),
+ };
+}
+
+/**
+ * Get geohash location
+ * @param event Relay discovery event (kind 30166)
+ * @returns Geohash string or undefined
+ */
+export function getRelayGeohash(event: NostrEvent): string | undefined {
+ return getTagValue(event, "g");
+}
+
+/**
+ * Parse NIP-11 document from content field
+ * @param event Relay discovery event (kind 30166)
+ * @returns Parsed NIP-11 object or undefined
+ */
+export function parseNip11Document(event: NostrEvent): object | undefined {
+ if (!event.content || event.content.trim() === "") {
+ return undefined;
+ }
+
+ try {
+ return JSON.parse(event.content);
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Calculate relay health score (0-100) based on RTT and event recency
+ * Lower RTT = higher score, more recent events = higher score
+ * @param event Relay discovery event (kind 30166)
+ * @returns Health score from 0 to 100
+ */
+export function calculateRelayHealth(event: NostrEvent): number {
+ let score = 100;
+
+ // Factor 1: RTT performance (up to -40 points)
+ const rtt = getRttMetrics(event);
+ const avgRtt = [rtt.open, rtt.read, rtt.write]
+ .filter((v): v is number => v !== undefined)
+ .reduce((sum, v, _, arr) => sum + v / arr.length, 0);
+
+ if (avgRtt) {
+ // Penalize high RTT: 0-100ms = no penalty, 100-1000ms = -20 points, >1000ms = -40 points
+ if (avgRtt > 1000) {
+ score -= 40;
+ } else if (avgRtt > 100) {
+ score -= ((avgRtt - 100) / 900) * 20;
+ }
+ }
+
+ // Factor 2: Event age (up to -60 points)
+ const now = Math.floor(Date.now() / 1000);
+ const ageInSeconds = now - event.created_at;
+ const ageInDays = ageInSeconds / (24 * 60 * 60);
+
+ // Penalize old events: <1 day = no penalty, 1-7 days = -30 points, >7 days = -60 points
+ if (ageInDays > 7) {
+ score -= 60;
+ } else if (ageInDays > 1) {
+ score -= ((ageInDays - 1) / 6) * 30;
+ }
+
+ return Math.max(0, Math.min(100, Math.round(score)));
+}
+
+// ============================================================================
+// Monitor Announcement Event Helpers (Kind 10166)
+// ============================================================================
+
+/**
+ * Get monitoring frequency in seconds
+ * @param event Monitor announcement event (kind 10166)
+ * @returns Frequency in seconds or undefined
+ */
+export function getMonitorFrequency(event: NostrEvent): number | undefined {
+ const freq = getTagValue(event, "frequency");
+ return freq ? parseInt(freq, 10) : undefined;
+}
+
+/**
+ * Get timeout configurations per check type
+ * Returns map of check type to timeout in milliseconds
+ * @param event Monitor announcement event (kind 10166)
+ * @returns Map of check type to timeout value
+ */
+export function getMonitorTimeouts(event: NostrEvent): Record {
+ const timeouts: Record = {};
+
+ const timeoutTags = event.tags.filter((t) => t[0] === "timeout");
+
+ for (const tag of timeoutTags) {
+ if (tag.length >= 3) {
+ const checkType = tag[1];
+ const timeout = parseInt(tag[2], 10);
+ if (checkType && !isNaN(timeout)) {
+ timeouts[checkType] = timeout;
+ }
+ }
+ }
+
+ return timeouts;
+}
+
+/**
+ * Get array of check types performed by this monitor
+ * @param event Monitor announcement event (kind 10166)
+ * @returns Array of check type strings (open, read, write, auth, nip11, dns, geo)
+ */
+export function getMonitorChecks(event: NostrEvent): string[] {
+ const checks = event.tags.filter((t) => t[0] === "c").map((t) => t[1]);
+ return Array.from(new Set(checks));
+}
+
+/**
+ * Get monitor's geohash location
+ * @param event Monitor announcement event (kind 10166)
+ * @returns Geohash string or undefined
+ */
+export function getMonitorGeohash(event: NostrEvent): string | undefined {
+ return getTagValue(event, "g");
+}
+
+/**
+ * Format frequency for human-readable display
+ * @param seconds Frequency in seconds
+ * @returns Formatted string (e.g., "5 minutes", "1 hour")
+ */
+export function formatFrequency(seconds: number): string {
+ if (seconds < 60) {
+ return seconds === 1 ? "1 second" : `${seconds} seconds`;
+ }
+
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) {
+ return minutes === 1 ? "1 minute" : `${minutes} minutes`;
+ }
+
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) {
+ return hours === 1 ? "1 hour" : `${hours} hours`;
+ }
+
+ const days = Math.floor(hours / 24);
+ return days === 1 ? "1 day" : `${days} days`;
+}
+
+/**
+ * Format timeout value for display
+ * @param milliseconds Timeout in milliseconds
+ * @returns Formatted string (e.g., "500ms", "2s")
+ */
+export function formatTimeout(milliseconds: number): string {
+ if (milliseconds < 1000) {
+ return `${milliseconds}ms`;
+ }
+
+ const seconds = milliseconds / 1000;
+ if (seconds < 60) {
+ return `${seconds}s`;
+ }
+
+ const minutes = Math.floor(seconds / 60);
+ return `${minutes}m`;
+}
+
+/**
+ * Get human-readable check type name
+ * @param checkType Check type code (e.g., "open", "read", "write")
+ * @returns Human-readable name
+ */
+export function getCheckTypeName(checkType: string): string {
+ const names: Record = {
+ open: "Connection",
+ read: "Read",
+ write: "Write",
+ auth: "Authentication",
+ nip11: "NIP-11 Info",
+ dns: "DNS",
+ geo: "Geolocation",
+ };
+
+ return names[checkType] || checkType;
+}