diff --git a/src/components/nostr/kinds/FavoriteScrollsRenderer.tsx b/src/components/nostr/kinds/FavoriteScrollsRenderer.tsx
new file mode 100644
index 0000000..27d0052
--- /dev/null
+++ b/src/components/nostr/kinds/FavoriteScrollsRenderer.tsx
@@ -0,0 +1,153 @@
+import { ScrollText, Star } from "lucide-react";
+import {
+ getScrollName,
+ getScrollParams,
+ getScrollContentSize,
+ getScrollIcon,
+ formatBytes,
+} from "@/lib/nip5c-helpers";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { ScrollIconImage } from "./ScrollRenderer";
+import { useNostrEvent } from "@/hooks/useNostrEvent";
+import { useFavoriteList, getListPointers } from "@/hooks/useFavoriteList";
+import { FAVORITE_LISTS } from "@/config/favorite-lists";
+import { SCROLL_KIND } from "@/constants/kinds";
+import { useAccount } from "@/hooks/useAccount";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { NostrEvent } from "@/types/nostr";
+import type { EventPointer } from "nostr-tools/nip19";
+
+/**
+ * Individual scroll reference item for the detail view
+ */
+function ScrollRefItem({
+ pointer,
+ onUnfavorite,
+ canModify,
+}: {
+ pointer: EventPointer;
+ onUnfavorite?: (event: NostrEvent) => void;
+ canModify: boolean;
+}) {
+ const scrollEvent = useNostrEvent(pointer);
+
+ if (!scrollEvent) {
+ return (
+
+ );
+ }
+
+ const name = getScrollName(scrollEvent);
+ const iconUrl = getScrollIcon(scrollEvent);
+ const params = getScrollParams(scrollEvent);
+ const contentSize = getScrollContentSize(scrollEvent);
+
+ return (
+
+
+
+
+ {name || "Unnamed Scroll"}
+
+
+ {params.length > 0 && (
+
+ {params.length} param{params.length !== 1 ? "s" : ""}
+
+ )}
+ {params.length > 0 && contentSize > 0 && · }
+ {contentSize > 0 && {formatBytes(contentSize)}}
+
+
+ {canModify && onUnfavorite && (
+
+ )}
+
+ );
+}
+
+/**
+ * Kind 10027 Renderer - Favorite Scrolls (Feed View)
+ */
+export function FavoriteScrollsRenderer({ event }: BaseEventProps) {
+ const pointers = getListPointers(event, "e");
+
+ return (
+
+
+
+
+ Favorite Scrolls
+
+
+
+ {pointers.length === 0
+ ? "No favorite scrolls"
+ : `${pointers.length} favorite scroll${pointers.length !== 1 ? "s" : ""}`}
+
+
+
+ );
+}
+
+/**
+ * Kind 10027 Detail Renderer - Favorite Scrolls (Full View)
+ */
+export function FavoriteScrollsDetailRenderer({
+ event,
+}: {
+ event: NostrEvent;
+}) {
+ const { canSign } = useAccount();
+ const { toggleFavorite } = useFavoriteList(FAVORITE_LISTS[SCROLL_KIND]);
+
+ const pointers = getListPointers(event, "e");
+
+ return (
+
+
+
+ Favorite Scrolls
+
+ ({pointers.length})
+
+
+
+ {pointers.length === 0 ? (
+
+ No favorite scrolls yet. Star a scroll to add it here.
+
+ ) : (
+
+ {pointers.map((pointer) => (
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/src/components/nostr/kinds/ScrollRenderer.tsx b/src/components/nostr/kinds/ScrollRenderer.tsx
new file mode 100644
index 0000000..ac2bb7f
--- /dev/null
+++ b/src/components/nostr/kinds/ScrollRenderer.tsx
@@ -0,0 +1,154 @@
+import { useState } from "react";
+import {
+ ScrollText,
+ Binary,
+ User,
+ FileText,
+ Type,
+ Hash,
+ Calendar,
+ Radio,
+} from "lucide-react";
+import {
+ getScrollName,
+ getScrollDescription,
+ getScrollIcon,
+ getScrollParams,
+ getScrollContentSize,
+ formatBytes,
+} from "@/lib/nip5c-helpers";
+import type { ScrollParamType } from "@/lib/nip5c-helpers";
+import {
+ BaseEventProps,
+ BaseEventContainer,
+ ClickableEventTitle,
+} from "./BaseEventRenderer";
+import { ScrollExecutor } from "@/components/scroll/ScrollExecutor";
+import type { NostrEvent } from "@/types/nostr";
+import type { LucideIcon } from "lucide-react";
+
+export function ScrollIconImage({
+ iconUrl,
+ className,
+}: {
+ iconUrl?: string;
+ className?: string;
+}) {
+ const [failed, setFailed] = useState(false);
+
+ if (!iconUrl || failed) {
+ return ;
+ }
+
+ return (
+
setFailed(true)}
+ />
+ );
+}
+
+export const PARAM_CONFIG: Record<
+ ScrollParamType,
+ { icon: LucideIcon; placeholder: string; inputType: string }
+> = {
+ public_key: {
+ icon: User,
+ placeholder: "hex pubkey or npub...",
+ inputType: "text",
+ },
+ event: {
+ icon: FileText,
+ placeholder: "event ID, note1..., or nevent...",
+ inputType: "text",
+ },
+ string: { icon: Type, placeholder: "text value...", inputType: "text" },
+ number: { icon: Hash, placeholder: "0", inputType: "number" },
+ timestamp: {
+ icon: Calendar,
+ placeholder: "unix timestamp",
+ inputType: "number",
+ },
+ relay: { icon: Radio, placeholder: "wss://...", inputType: "text" },
+};
+
+export function ScrollRenderer({ event }: BaseEventProps) {
+ const name = getScrollName(event);
+ const description = getScrollDescription(event);
+ const iconUrl = getScrollIcon(event);
+ const params = getScrollParams(event);
+ const contentSize = getScrollContentSize(event);
+
+ return (
+
+
+
+
+ {name || "Unnamed Scroll"}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {params.length > 0 && (
+
+ {params.map((param) => {
+ const { icon: Icon } = PARAM_CONFIG[param.type];
+ return (
+
+
+ {param.name}
+
+ );
+ })}
+
+ )}
+
+ {contentSize > 0 && (
+
+
+ {formatBytes(contentSize)}
+
+ )}
+
+
+ );
+}
+
+export function ScrollDetailRenderer({ event }: { event: NostrEvent }) {
+ const name = getScrollName(event);
+ const description = getScrollDescription(event);
+ const iconUrl = getScrollIcon(event);
+ const contentSize = getScrollContentSize(event);
+ const params = getScrollParams(event);
+
+ return (
+
+
+
+
+
{name || "Unnamed Scroll"}
+
+ {description && (
+
{description}
+ )}
+ {contentSize > 0 && (
+
+
+ ~{formatBytes(contentSize)} WASM
+
+ )}
+
+
+
+
+ );
+}
diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx
index 72465cd..afa4f29 100644
--- a/src/components/nostr/kinds/index.tsx
+++ b/src/components/nostr/kinds/index.tsx
@@ -190,6 +190,11 @@ import {
NsiteNamedRenderer,
NsiteLegacyRenderer,
} from "./NsiteRenderer";
+import { ScrollRenderer, ScrollDetailRenderer } from "./ScrollRenderer";
+import {
+ FavoriteScrollsRenderer,
+ FavoriteScrollsDetailRenderer,
+} from "./FavoriteScrollsRenderer";
import {
NsiteRootDetailRenderer,
NsiteNamedDetailRenderer,
@@ -222,6 +227,7 @@ const kindRenderers: Record> = {
1111: Kind1111Renderer, // Post (NIP-22)
1222: VoiceMessageRenderer, // Voice Message (NIP-A0)
1311: LiveChatMessageRenderer, // Live Chat Message (NIP-53)
+ 1227: ScrollRenderer, // Scroll (NIP-5C)
1244: VoiceMessageRenderer, // Voice Message Reply (NIP-A0)
1337: Kind1337Renderer, // Code Snippet (NIP-C0)
3367: ColorMomentRenderer, // Color Moment
@@ -253,6 +259,7 @@ const kindRenderers: Record> = {
10017: GitAuthorsRenderer, // Git Authors (NIP-51)
10018: FavoriteReposRenderer, // Favorite Repositories (NIP-51)
10020: MediaFollowListRenderer, // Media Follow List (NIP-51)
+ 10027: FavoriteScrollsRenderer, // Favorite Scrolls (NIP-5C)
10030: EmojiListRenderer, // User Emoji List (NIP-51)
10040: TrustedProviderListRenderer, // Trusted Provider List (NIP-85)
10050: GenericRelayListRenderer, // DM Relay List (NIP-51)
@@ -356,6 +363,7 @@ const detailRenderers: Record<
8: BadgeAwardDetailRenderer, // Badge Award Detail (NIP-58)
777: SpellDetailRenderer, // Spell Detail
1068: PollDetailRenderer, // Poll Detail (NIP-88)
+ 1227: ScrollDetailRenderer, // Scroll Detail (NIP-5C)
1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0)
3367: ColorMomentDetailRenderer, // Color Moment Detail
1617: PatchDetailRenderer, // Patch Detail (NIP-34)
@@ -381,6 +389,7 @@ const detailRenderers: Record<
10018: FavoriteReposDetailRenderer, // Favorite Repositories Detail (NIP-34)
10040: TrustedProviderListDetailRenderer, // Trusted Provider List Detail (NIP-85)
10020: MediaFollowListDetailRenderer, // Media Follow List Detail (NIP-51)
+ 10027: FavoriteScrollsDetailRenderer, // Favorite Scrolls Detail (NIP-5C)
10030: EmojiListDetailRenderer, // User Emoji List Detail (NIP-51)
10063: BlossomServerListDetailRenderer, // Blossom User Server List Detail (BUD-03)
10101: WikiAuthorsDetailRenderer, // Good Wiki Authors Detail (NIP-51)
diff --git a/src/components/scroll/ScrollControls.tsx b/src/components/scroll/ScrollControls.tsx
new file mode 100644
index 0000000..c185106
--- /dev/null
+++ b/src/components/scroll/ScrollControls.tsx
@@ -0,0 +1,99 @@
+import { Play, Square, Loader2, Settings } from "lucide-react";
+import { Button } from "@/components/ui/button";
+import {
+ DropdownMenu,
+ DropdownMenuTrigger,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuCheckboxItem,
+} from "@/components/ui/dropdown-menu";
+import type { ScrollRuntimeState } from "@/lib/scroll-runtime";
+
+interface ScrollControlsProps {
+ runtimeState: ScrollRuntimeState;
+ onRun: () => void;
+ onStop: () => void;
+ runDisabled?: boolean;
+ endianness: "LE" | "BE";
+ presenceBytes: boolean;
+ onEndiannessChange: (v: "LE" | "BE") => void;
+ onPresenceBytesChange: (v: boolean) => void;
+}
+
+export function ScrollControls({
+ runtimeState,
+ onRun,
+ onStop,
+ runDisabled,
+ endianness,
+ presenceBytes,
+ onEndiannessChange,
+ onPresenceBytesChange,
+}: ScrollControlsProps) {
+ const canRun =
+ runtimeState === "idle" ||
+ runtimeState === "stopped" ||
+ runtimeState === "completed" ||
+ runtimeState === "error";
+ const isActive = runtimeState === "loading" || runtimeState === "running";
+
+ return (
+
+
+
+
+
+
+
+
+
+ Encoding
+
+ onEndiannessChange(v as "LE" | "BE")}
+ >
+
+ Little-endian (spec)
+
+
+ Big-endian (legacy)
+
+
+
+ onPresenceBytesChange(v === true)}
+ >
+ Presence bytes
+
+
+
+
+ );
+}
diff --git a/src/components/scroll/ScrollExecutor.tsx b/src/components/scroll/ScrollExecutor.tsx
new file mode 100644
index 0000000..6c39a12
--- /dev/null
+++ b/src/components/scroll/ScrollExecutor.tsx
@@ -0,0 +1,188 @@
+import { useState, useRef, useCallback, useEffect, useMemo } from "react";
+import { resolveParamValue } from "@/lib/nip5c-helpers";
+import type { ScrollParam, ParamValue } from "@/lib/nip5c-helpers";
+import {
+ runScroll,
+ fetchEventParam,
+ type ScrollRuntimeController,
+ type ScrollRuntimeState,
+ type TraceEntry,
+ type SubscriptionInfo,
+} from "@/lib/scroll-runtime";
+import { useAccount } from "@/hooks/useAccount";
+import { useRelayState } from "@/hooks/useRelayState";
+import { ScrollParamForm } from "./ScrollParamForm";
+import { ScrollControls } from "./ScrollControls";
+import { ScrollOutput } from "./ScrollOutput";
+import type { NostrEvent } from "@/types/nostr";
+
+interface ScrollExecutorProps {
+ /** Parsed parameter definitions */
+ params: ScrollParam[];
+ /** Base64-encoded WASM binary */
+ wasmBase64: string;
+}
+
+export function ScrollExecutor({ params, wasmBase64 }: ScrollExecutorProps) {
+ const { pubkey } = useAccount();
+ const { relays: relayStates } = useRelayState();
+
+ const connectedRelays = Object.entries(relayStates)
+ .filter(([, state]) => state.connectionState === "connected")
+ .map(([url]) => url);
+
+ // Pre-fill "me" params with logged-in pubkey
+ const defaultValues: Record = {};
+ for (const p of params) {
+ if (p.name === "me" && p.type === "public_key" && pubkey) {
+ defaultValues[p.name] = pubkey;
+ }
+ }
+
+ const [runtimeState, setRuntimeState] = useState("idle");
+ const [paramValues, setParamValues] =
+ useState>(defaultValues);
+ const [displayedEventsMap, setDisplayedEventsMap] = useState<
+ Map
+ >(new Map());
+ const [logEntries, setLogEntries] = useState([]);
+ const [traceEntries, setTraceEntries] = useState([]);
+ const [activeSubs, setActiveSubs] = useState([]);
+ const [eventCount, setEventCount] = useState(0);
+ const controllerRef = useRef(null);
+
+ // Encoding options
+ const [endianness, setEndianness] = useState<"LE" | "BE">("BE");
+ const [presenceBytes, setPresenceBytes] = useState(false);
+
+ const isActive = runtimeState === "loading" || runtimeState === "running";
+
+ // Sorted, deduplicated display events (newest first)
+ const displayedEvents = useMemo(
+ () =>
+ Array.from(displayedEventsMap.values()).sort(
+ (a, b) => b.created_at - a.created_at,
+ ),
+ [displayedEventsMap],
+ );
+
+ const requiredParamsMissing = params.some(
+ (p) => p.required && !paramValues[p.name]?.trim(),
+ );
+
+ useEffect(() => {
+ return () => {
+ controllerRef.current?.stop();
+ };
+ }, []);
+
+ const handleRun = useCallback(async () => {
+ controllerRef.current?.stop();
+
+ setDisplayedEventsMap(new Map());
+ setLogEntries([]);
+ setTraceEntries([]);
+ setActiveSubs([]);
+ setEventCount(0);
+ setRuntimeState("loading");
+
+ // Resolve param values — fetch all event params before running
+ const resolved = new Map();
+ for (const param of params) {
+ const raw = paramValues[param.name];
+ if (!raw?.trim()) {
+ if (param.required) {
+ setLogEntries([`Error: required param "${param.name}" is missing`]);
+ setRuntimeState("error");
+ return;
+ }
+ continue;
+ }
+ const value = resolveParamValue(param.type, raw);
+ if (value === null) {
+ setLogEntries([
+ `Error: invalid value for param "${param.name}" (type: ${param.type})`,
+ ]);
+ setRuntimeState("error");
+ return;
+ }
+
+ if (param.type === "event" && typeof value === "string") {
+ const eventObj = await fetchEventParam(value);
+ if (!eventObj) {
+ setLogEntries([
+ `Error: could not fetch event "${value}" for param "${param.name}"`,
+ ]);
+ setRuntimeState("error");
+ return;
+ }
+ resolved.set(param.name, eventObj);
+ } else {
+ resolved.set(param.name, value);
+ }
+ }
+
+ try {
+ const controller = await runScroll(wasmBase64, params, {
+ paramValues: resolved,
+ endianness,
+ presenceBytes,
+ onDisplay: (ev) =>
+ setDisplayedEventsMap((prev) => {
+ if (prev.has(ev.id)) return prev;
+ const next = new Map(prev);
+ next.set(ev.id, ev);
+ return next;
+ }),
+ onLog: (msg) => setLogEntries((prev) => [...prev, msg]),
+ onStateChange: setRuntimeState,
+ onEventCount: setEventCount,
+ onSubscriptionsChange: setActiveSubs,
+ onTrace: (entry) => setTraceEntries((prev) => [...prev, entry]),
+ onError: (err) =>
+ setLogEntries((prev) => [...prev, `Error: ${err.message}`]),
+ });
+ controllerRef.current = controller;
+ } catch (err) {
+ const msg = err instanceof Error ? err.message : String(err);
+ setLogEntries((prev) => [...prev, `Fatal: ${msg}`]);
+ setRuntimeState("error");
+ }
+ }, [wasmBase64, params, paramValues, endianness, presenceBytes]);
+
+ const handleStop = useCallback(() => {
+ controllerRef.current?.stop();
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/scroll/ScrollOutput.tsx b/src/components/scroll/ScrollOutput.tsx
new file mode 100644
index 0000000..a1de183
--- /dev/null
+++ b/src/components/scroll/ScrollOutput.tsx
@@ -0,0 +1,285 @@
+import { useState, memo } from "react";
+import {
+ List,
+ Terminal,
+ Wifi,
+ Activity,
+ FileText,
+ ChevronRight,
+ ChevronDown,
+ GalleryVertical,
+} from "lucide-react";
+import { Virtuoso } from "react-virtuoso";
+import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
+import { Label } from "@/components/ui/label";
+import { RelayLink } from "@/components/nostr/RelayLink";
+import { FeedEvent } from "@/components/nostr/Feed";
+import { MemoizedCompactEventRow } from "@/components/nostr/CompactEventRow";
+import { CopyableJsonViewer } from "@/components/JsonViewer";
+import type { NostrEvent } from "@/types/nostr";
+import type { TraceEntry, SubscriptionInfo } from "@/lib/scroll-runtime";
+
+function TraceRow({ entry }: { entry: TraceEntry }) {
+ const [expanded, setExpanded] = useState(false);
+ const hasDetail = entry.args || entry.result;
+
+ return (
+
+
hasDetail && setExpanded(!expanded)}
+ >
+ {hasDetail ? (
+ expanded ? (
+
+ ) : (
+
+ )
+ ) : (
+
+ )}
+
+ {entry.direction === "program" ? "program" : "host"}
+
+ {entry.fn}
+
+ {expanded && hasDetail && (
+
+
+
+ )}
+
+ );
+}
+
+function EmptyState({ message }: { message: string }) {
+ return (
+
+ );
+}
+
+interface ScrollOutputProps {
+ displayedEvents: NostrEvent[];
+ logEntries: string[];
+ traceEntries: TraceEntry[];
+ activeSubs: SubscriptionInfo[];
+ eventCount: number;
+ isActive: boolean;
+}
+
+const MemoizedFeedEvent = memo(
+ FeedEvent,
+ (prev, next) => prev.event.id === next.event.id,
+);
+
+export function ScrollOutput({
+ displayedEvents,
+ logEntries,
+ traceEntries,
+ activeSubs,
+ eventCount,
+ isActive,
+}: ScrollOutputProps) {
+ const [compact, setCompact] = useState(false);
+ const openSubsCount = activeSubs.filter((s) => !s.closed).length;
+
+ return (
+
+
+
+
+
+ Results
+
+
+
+ Logs
+
+
+
+ Subs
+
+
+
+ Trace
+
+
+
+
+
+
+
+
+
+ {openSubsCount}
+
+
+
+ {eventCount}
+
+
+
+ {displayedEvents.length}
+
+
+
+
+
+ {displayedEvents.length === 0 ? (
+
+ ) : (
+ ev.id}
+ itemContent={(_index, ev) =>
+ compact ? (
+
+ ) : (
+
+ )
+ }
+ />
+ )}
+
+
+
+
+ {logEntries.length === 0 ? (
+
+ ) : (
+ index}
+ itemContent={(_index, entry) => (
+
+ {entry}
+
+ )}
+ />
+ )}
+
+
+
+ {activeSubs.length === 0 ? (
+
+ ) : (
+
+ {[...activeSubs]
+ .sort((a, b) => a.handle - b.handle)
+ .map((sub) => (
+
+
+
+ SUB #{sub.handle}
+
+
+ {sub.eosed && }
+
+
+ {sub.closed ? "closed" : "open"}
+
+
+
+
Filter:
+
+ {JSON.stringify(sub.filter, null, 2)}
+
+
+ {sub.relays.length > 0 && (
+
+
+ Relays ({sub.relays.length}):
+
+
+ {sub.relays.map((url) => (
+
+ ))}
+
+
+ )}
+
+ ))}
+
+ )}
+
+
+
+ {traceEntries.length === 0 ? (
+
+ ) : (
+ index}
+ itemContent={(_index, entry) => }
+ />
+ )}
+
+
+ );
+}
diff --git a/src/components/scroll/ScrollParamForm.tsx b/src/components/scroll/ScrollParamForm.tsx
new file mode 100644
index 0000000..b3f858b
--- /dev/null
+++ b/src/components/scroll/ScrollParamForm.tsx
@@ -0,0 +1,109 @@
+import { Settings } from "lucide-react";
+import { PARAM_CONFIG } from "@/components/nostr/kinds/ScrollRenderer";
+import { Label } from "@/components/ui/label";
+import { Input } from "@/components/ui/input";
+import type { ScrollParam } from "@/lib/nip5c-helpers";
+
+interface ScrollParamFormProps {
+ params: ScrollParam[];
+ values: Record;
+ onChange: (values: Record) => void;
+ connectedRelays: string[];
+ disabled?: boolean;
+}
+
+export function ScrollParamForm({
+ params,
+ values,
+ onChange,
+ connectedRelays,
+ disabled,
+}: ScrollParamFormProps) {
+ if (params.length === 0) return null;
+
+ const setValue = (name: string, value: string) => {
+ onChange({ ...values, [name]: value });
+ };
+
+ return (
+
+
+
+ Parameters
+
+
+ {params.map((param) => {
+ const {
+ icon: Icon,
+ placeholder,
+ inputType,
+ } = PARAM_CONFIG[param.type];
+
+ return (
+
+
+
+
+ {param.name}
+
+
+ {param.required && }
+ {param.supportedKinds && (
+
+ kinds: {param.supportedKinds}
+
+ )}
+
+ {param.description && (
+
+ {param.description}
+
+ )}
+ {param.type === "relay" ? (
+
+
+ setValue(param.name, e.target.value)}
+ disabled={disabled}
+ className="flex-1 h-8 text-xs"
+ />
+
+ ) : (
+
setValue(param.name, e.target.value)}
+ disabled={disabled}
+ className="h-8 text-xs"
+ />
+ )}
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/config/favorite-lists.ts b/src/config/favorite-lists.ts
index 2a382a8..8d5c4fa 100644
--- a/src/config/favorite-lists.ts
+++ b/src/config/favorite-lists.ts
@@ -1,4 +1,4 @@
-import { SPELL_KIND } from "@/constants/kinds";
+import { SPELL_KIND, SCROLL_KIND } from "@/constants/kinds";
import type { TagStrategy } from "@/lib/favorite-tag-strategies";
import { groupTagStrategy } from "@/lib/favorite-tag-strategies";
@@ -36,6 +36,11 @@ export const FAVORITE_LISTS: Record = {
elementKind: 30030,
label: "Emoji Sets",
},
+ [SCROLL_KIND]: {
+ listKind: 10027,
+ elementKind: SCROLL_KIND,
+ label: "Favorite Scrolls",
+ },
39000: {
listKind: 10009,
elementKind: 39000,
diff --git a/src/constants/kinds.ts b/src/constants/kinds.ts
index ed90554..96b9b01 100644
--- a/src/constants/kinds.ts
+++ b/src/constants/kinds.ts
@@ -75,6 +75,7 @@ import {
WandSparkles,
XCircle,
Zap,
+ ScrollText,
type LucideIcon,
} from "lucide-react";
@@ -96,6 +97,7 @@ export interface EventKind {
export const SPELL_KIND = 777;
export const SPELLBOOK_KIND = 30777;
+export const SCROLL_KIND = 1227;
export const EVENT_KINDS: Record = {
// Core protocol kinds
@@ -473,6 +475,13 @@ export const EVENT_KINDS: Record = {
nip: "A0",
icon: Mic,
},
+ 1227: {
+ kind: 1227,
+ name: "Scroll",
+ description: "WebAssembly Scroll Program",
+ nip: "5C",
+ icon: ScrollText,
+ },
1311: {
kind: 1311,
name: "Live Chat",
@@ -868,6 +877,13 @@ export const EVENT_KINDS: Record = {
nip: "51",
icon: Play,
},
+ 10027: {
+ kind: 10027,
+ name: "Favorite Scrolls",
+ description: "Favorite scrolls list",
+ nip: "5C",
+ icon: ScrollText,
+ },
10030: {
kind: 10030,
name: "Emoji List",
diff --git a/src/lib/nip5c-helpers.ts b/src/lib/nip5c-helpers.ts
new file mode 100644
index 0000000..1cc4b2f
--- /dev/null
+++ b/src/lib/nip5c-helpers.ts
@@ -0,0 +1,135 @@
+import type { NostrEvent } from "@/types/nostr";
+import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers";
+import { nip19 } from "nostr-tools";
+import { hexToBytes } from "@noble/hashes/utils";
+import { isValidHexPubkey, isValidHexEventId } from "@/lib/nostr-validation";
+import { isValidRelayURL } from "@/lib/relay-url";
+
+/**
+ * NIP-5C Helper Functions
+ * Utility functions for parsing NIP-5C Scroll events (kind 1227)
+ *
+ * All helper functions use applesauce's getOrComputeCachedValue to cache
+ * computed values on the event object itself. This means you don't need
+ * useMemo when calling these functions.
+ */
+
+export type ScrollParamType =
+ | "public_key"
+ | "event"
+ | "string"
+ | "number"
+ | "timestamp"
+ | "relay";
+
+export interface ScrollParam {
+ name: string;
+ description: string;
+ type: ScrollParamType;
+ required: boolean;
+ /** For event params, comma-separated list of supported kinds */
+ supportedKinds?: string;
+}
+
+const VALID_PARAM_TYPES = new Set([
+ "public_key",
+ "event",
+ "string",
+ "number",
+ "timestamp",
+ "relay",
+]);
+
+// Cache symbols
+const ScrollParamsSymbol = Symbol("scrollParams");
+const ScrollContentSizeSymbol = Symbol("scrollContentSize");
+
+export function getScrollName(event: NostrEvent): string | undefined {
+ return getTagValue(event, "name");
+}
+
+export function getScrollDescription(event: NostrEvent): string | undefined {
+ return getTagValue(event, "description");
+}
+
+export function getScrollIcon(event: NostrEvent): string | undefined {
+ return getTagValue(event, "icon");
+}
+
+/** Parses ["param", name, description, type, required, ...extra] tags */
+export function getScrollParams(event: NostrEvent): ScrollParam[] {
+ return getOrComputeCachedValue(event, ScrollParamsSymbol, () =>
+ event.tags
+ .filter((t) => t[0] === "param" && t[1])
+ .map((t) => ({
+ name: t[1],
+ description: t[2] || "",
+ type: (VALID_PARAM_TYPES.has(t[3])
+ ? t[3]
+ : "string") as ScrollParamType,
+ required: t[4] === "required",
+ supportedKinds: t[3] === "event" && t[5] ? t[5] : undefined,
+ })),
+ );
+}
+
+/** Estimates decoded WASM binary size from base64 content length */
+export function getScrollContentSize(event: NostrEvent): number {
+ return getOrComputeCachedValue(event, ScrollContentSizeSymbol, () => {
+ if (!event.content) return 0;
+ // base64 encodes 3 bytes per 4 chars, minus padding
+ const padding = (event.content.match(/=+$/) || [""])[0].length;
+ return Math.floor((event.content.length * 3) / 4) - padding;
+ });
+}
+
+export function formatBytes(bytes: number): string {
+ if (bytes === 0) return "0 B";
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+}
+
+export type ParamValue = Uint8Array | string | number | NostrEvent;
+
+/** For "event" type, returns the event ID string — caller must fetch the actual event */
+export function resolveParamValue(
+ type: ScrollParamType,
+ rawValue: string,
+): ParamValue | null {
+ const trimmed = rawValue.trim();
+ if (!trimmed) return null;
+
+ switch (type) {
+ case "public_key": {
+ if (trimmed.startsWith("npub")) {
+ try {
+ const decoded = nip19.decode(trimmed);
+ if (decoded.type === "npub") return hexToBytes(decoded.data);
+ } catch {
+ return null;
+ }
+ }
+ return isValidHexPubkey(trimmed) ? hexToBytes(trimmed) : null;
+ }
+ case "event": {
+ try {
+ const decoded = nip19.decode(trimmed);
+ if (decoded.type === "nevent") return decoded.data.id;
+ if (decoded.type === "note") return decoded.data;
+ } catch {
+ // Not a bech32 string — fall through to hex check
+ }
+ return isValidHexEventId(trimmed) ? trimmed : null;
+ }
+ case "string":
+ return trimmed;
+ case "number":
+ case "timestamp": {
+ const n = parseInt(trimmed, 10);
+ return isNaN(n) ? null : n;
+ }
+ case "relay":
+ return isValidRelayURL(trimmed) ? trimmed : null;
+ }
+}
diff --git a/src/lib/scroll-runtime.ts b/src/lib/scroll-runtime.ts
new file mode 100644
index 0000000..a091581
--- /dev/null
+++ b/src/lib/scroll-runtime.ts
@@ -0,0 +1,862 @@
+/**
+ * NIP-5C Scroll WASM Host Runtime
+ *
+ * Standalone host that instantiates and runs NIP-5C scroll programs.
+ * Provides the full nostr.* import API to WASM modules.
+ *
+ * Each execution uses a dedicated RelayPool and EventStore for isolation.
+ * The global eventStore is only used for relay selection (NIP-65 relay lists).
+ */
+
+import type { NostrEvent } from "@/types/nostr";
+import type { Filter } from "nostr-tools";
+import type { Subscription } from "rxjs";
+import { firstValueFrom, timeout as rxTimeout } from "rxjs";
+import { RelayPool } from "applesauce-relay";
+import { EventStore } from "applesauce-core";
+import { hexToBytes, bytesToHex } from "@noble/hashes/utils";
+import globalEventStore from "@/services/event-store";
+import { selectRelaysForFilter } from "@/services/relay-selection";
+import { AGGREGATOR_RELAYS, eventLoader } from "@/services/loaders";
+import type { ScrollParam, ParamValue } from "@/lib/nip5c-helpers";
+import { isNostrEvent } from "@/lib/type-guards";
+
+const textEncoder = new TextEncoder();
+const textDecoder = new TextDecoder();
+
+/**
+ * Fetch a NostrEvent by ID, checking the global store first then relays.
+ */
+export async function fetchEventParam(
+ eventId: string,
+): Promise {
+ const existing = globalEventStore.getEvent(eventId);
+ if (existing) return existing;
+ try {
+ return await firstValueFrom(
+ eventLoader({ id: eventId }).pipe(rxTimeout(10_000)),
+ );
+ } catch {
+ return undefined;
+ }
+}
+
+// --- Types ---
+
+export type ScrollRuntimeState =
+ | "idle"
+ | "loading"
+ | "running"
+ | "stopped"
+ | "completed"
+ | "error";
+
+export type TraceDirection = "program" | "host";
+
+export interface TraceEntry {
+ direction: TraceDirection;
+ fn: string;
+ args?: Record;
+ result?: Record;
+ timestamp: number;
+}
+
+export interface ScrollRuntimeOptions {
+ paramValues: Map;
+ onDisplay: (event: NostrEvent) => void;
+ onLog: (message: string) => void;
+ onStateChange: (state: ScrollRuntimeState) => void;
+ /** Called with total event count when any event is received from subscriptions */
+ onEventCount?: (count: number) => void;
+ /** Called when subscriptions change (opened, closed, events received) */
+ onSubscriptionsChange?: (subs: SubscriptionInfo[]) => void;
+ /** Called for every host↔WASM function call (debug trace) */
+ onTrace?: (entry: TraceEntry) => void;
+ onError?: (error: Error) => void;
+ /** Byte order for host-written numbers. Default: "LE" (NIP-5C spec) */
+ endianness?: "LE" | "BE";
+ /** Whether to prefix each param with a presence byte. Default: true (NIP-5C spec) */
+ presenceBytes?: boolean;
+}
+
+export interface SubscriptionInfo {
+ handle: number;
+ filter: Filter;
+ relays: string[];
+ eventCount: number;
+ eosed: boolean;
+ closed: boolean;
+}
+
+export interface ScrollRuntimeController {
+ stop: () => void;
+}
+
+interface ReqData {
+ filter: Filter;
+ relays: string[];
+ closeOnEose: boolean;
+}
+
+interface SubData {
+ rxSubscription: Subscription | null;
+ filter: Filter;
+ relays: string[];
+ eventCount: number;
+ eosed: boolean;
+}
+
+type HandleEntry =
+ | { type: "req"; data: ReqData }
+ | { type: "event"; data: NostrEvent }
+ | { type: "sub"; data: SubData };
+
+function pushUnique(arr: T[], value: T): void {
+ if (!arr.includes(value)) arr.push(value);
+}
+
+export async function runScroll(
+ wasmBase64: string,
+ paramSpecs: ScrollParam[],
+ options: ScrollRuntimeOptions,
+): Promise {
+ let stopped = false;
+ let totalEventsReceived = 0;
+ const littleEndian = (options.endianness ?? "LE") === "LE";
+ const usePresenceBytes = options.presenceBytes ?? true;
+
+ // Dedicated instances for this execution
+ const privatePool = new RelayPool();
+ const privateEventStore = new EventStore();
+
+ let nextHandleId = 1;
+ const handles = new Map();
+ const closedSubs: SubscriptionInfo[] = [];
+
+ function allocHandle(type: "req", data: ReqData): number;
+ function allocHandle(type: "event", data: NostrEvent): number;
+ function allocHandle(type: "sub", data: SubData): number;
+ function allocHandle(type: string, data: unknown): number {
+ const h = nextHandleId++;
+ handles.set(h, { type, data } as HandleEntry);
+ return h;
+ }
+
+ function getReq(h: number): ReqData {
+ const entry = handles.get(h);
+ if (!entry || entry.type !== "req")
+ throw new Error(`bad handle ${h} for type req`);
+ return entry.data;
+ }
+
+ function getEvent(h: number): NostrEvent {
+ const entry = handles.get(h);
+ if (!entry || entry.type !== "event")
+ throw new Error(`bad handle ${h} for type event`);
+ return entry.data;
+ }
+
+ function dropHandle(h: number): void {
+ if (h === 0) return;
+ const entry = handles.get(h);
+ if (!entry) return;
+ if (entry.type === "sub" && entry.data.rxSubscription) {
+ entry.data.rxSubscription.unsubscribe();
+ }
+ handles.delete(h);
+ }
+
+ // State management
+ function setState(s: ScrollRuntimeState): void {
+ options.onStateChange(s);
+ }
+
+ function snapshotSub(
+ h: number,
+ data: SubData,
+ closed: boolean,
+ ): SubscriptionInfo {
+ return {
+ handle: h,
+ filter: data.filter,
+ relays: data.relays,
+ eventCount: data.eventCount,
+ eosed: data.eosed,
+ closed,
+ };
+ }
+
+ function getAllSubscriptions(): SubscriptionInfo[] {
+ const active: SubscriptionInfo[] = [];
+ for (const [h, entry] of handles) {
+ if (entry.type === "sub") {
+ active.push(snapshotSub(h, entry.data, false));
+ }
+ }
+ return [...closedSubs, ...active];
+ }
+
+ let subsNotifyPending = false;
+ function notifySubsChanged(): void {
+ if (subsNotifyPending || !options.onSubscriptionsChange) return;
+ subsNotifyPending = true;
+ queueMicrotask(() => {
+ subsNotifyPending = false;
+ if (!stopped) {
+ options.onSubscriptionsChange?.(getAllSubscriptions());
+ }
+ });
+ }
+
+ function trace(
+ direction: TraceDirection,
+ fn: string,
+ args?: Record,
+ result?: Record,
+ ): void {
+ if (!options.onTrace) return;
+ options.onTrace({ direction, fn, args, result, timestamp: Date.now() });
+ }
+
+ // WASM instance references
+ let instance: WebAssembly.Instance | null = null;
+ let memory: WebAssembly.Memory;
+
+ // --- Memory I/O ---
+
+ function readBuf(ptr: number, len: number): Uint8Array {
+ return new Uint8Array(memory.buffer.slice(ptr, ptr + len));
+ }
+
+ function readStr(ptr: number, len: number): string {
+ return textDecoder.decode(new Uint8Array(memory.buffer, ptr, len));
+ }
+
+ function alloc(size: number): number {
+ return (instance!.exports.alloc as (size: number) => number)(size);
+ }
+
+ function writeHostBuf(buf: Uint8Array, prefixLen: boolean): number {
+ const prefixOffset = prefixLen ? 4 : 0;
+ const ptr = alloc(buf.length + prefixOffset);
+ const view = new DataView(memory.buffer);
+ if (prefixLen) {
+ view.setUint32(ptr, buf.length, littleEndian);
+ }
+ new Uint8Array(memory.buffer, ptr + prefixOffset, buf.length).set(buf);
+ return ptr;
+ }
+
+ function writeHostStr(str: string, prefixLen: boolean = true): number {
+ const encoded = textEncoder.encode(str);
+ return writeHostBuf(encoded, prefixLen);
+ }
+
+ // --- Build WASM imports ---
+
+ const nostrImports = {
+ req_new: (): number => {
+ if (stopped) return 0;
+ const h = allocHandle("req", {
+ filter: {},
+ relays: [],
+ closeOnEose: false,
+ });
+ trace("program", "req_new", undefined, { handle: h });
+ return h;
+ },
+
+ req_add_author: (req: number, ptr: number): void => {
+ if (stopped) return;
+ const author = bytesToHex(readBuf(ptr, 32));
+ const r = getReq(req);
+ r.filter.authors = r.filter.authors || [];
+ pushUnique(r.filter.authors, author);
+ trace("program", "req_add_author", { req, author });
+ },
+
+ req_add_author_hex: (req: number, ptr: number): void => {
+ if (stopped) return;
+ const author = readStr(ptr, 64);
+ const r = getReq(req);
+ r.filter.authors = r.filter.authors || [];
+ pushUnique(r.filter.authors, author);
+ trace("program", "req_add_author_hex", { req, author });
+ },
+
+ req_add_id: (req: number, ptr: number): void => {
+ if (stopped) return;
+ const id = bytesToHex(readBuf(ptr, 32));
+ const r = getReq(req);
+ r.filter.ids = r.filter.ids || [];
+ pushUnique(r.filter.ids, id);
+ trace("program", "req_add_id", { req, id });
+ },
+
+ req_add_id_hex: (req: number, ptr: number): void => {
+ if (stopped) return;
+ const id = readStr(ptr, 64);
+ const r = getReq(req);
+ r.filter.ids = r.filter.ids || [];
+ pushUnique(r.filter.ids, id);
+ trace("program", "req_add_id_hex", { req, id });
+ },
+
+ req_add_kind: (req: number, kind: number): void => {
+ if (stopped) return;
+ const r = getReq(req);
+ r.filter.kinds = r.filter.kinds || [];
+ pushUnique(r.filter.kinds, kind);
+ trace("program", "req_add_kind", { req, kind });
+ },
+
+ req_add_tag: (
+ req: number,
+ tag: number,
+ vPtr: number,
+ vLen: number,
+ ): void => {
+ if (stopped) return;
+ const code = tag & 0xff;
+ const tagChar = String.fromCharCode(code);
+ if (!/^[A-Za-z]$/.test(tagChar)) return;
+ const value = readStr(vPtr, vLen);
+ const r = getReq(req);
+ const key = `#${tagChar}` as `#${string}`;
+ const tags = (r.filter as Record)[key] || [];
+ pushUnique(tags, value);
+ (r.filter as Record)[key] = tags;
+ trace("program", "req_add_tag", { req, tag: `#${tagChar}`, value });
+ },
+
+ req_add_tag_bin32: (req: number, tag: number, vPtr: number): void => {
+ if (stopped) return;
+ const code = tag & 0xff;
+ const tagChar = String.fromCharCode(code);
+ if (!/^[A-Za-z]$/.test(tagChar)) return;
+ const value = bytesToHex(readBuf(vPtr, 32));
+ const r = getReq(req);
+ const key = `#${tagChar}` as `#${string}`;
+ const tags = (r.filter as Record)[key] || [];
+ pushUnique(tags, value);
+ (r.filter as Record)[key] = tags;
+ trace("program", "req_add_tag_bin32", {
+ req,
+ tag: `#${tagChar}`,
+ value,
+ });
+ },
+
+ req_set_limit: (req: number, n: number): void => {
+ if (stopped) return;
+ getReq(req).filter.limit = n;
+ trace("program", "req_set_limit", { req, limit: n });
+ },
+
+ req_set_since: (req: number, ts: number): void => {
+ if (stopped) return;
+ getReq(req).filter.since = ts;
+ trace("program", "req_set_since", { req, since: ts });
+ },
+
+ req_set_until: (req: number, ts: number): void => {
+ if (stopped) return;
+ getReq(req).filter.until = ts;
+ trace("program", "req_set_until", { req, until: ts });
+ },
+
+ req_set_search: (req: number, ptr: number, len: number): void => {
+ if (stopped) return;
+ const search = readStr(ptr, len);
+ getReq(req).filter.search = search;
+ trace("program", "req_set_search", { req, search });
+ },
+
+ req_add_relay: (req: number, ptr: number, len: number): void => {
+ if (stopped) return;
+ const relay = readStr(ptr, len);
+ getReq(req).relays.push(relay);
+ trace("program", "req_add_relay", { req, relay });
+ },
+
+ req_close_on_eose: (req: number): void => {
+ if (stopped) return;
+ getReq(req).closeOnEose = true;
+ trace("program", "req_close_on_eose", { req });
+ },
+
+ subscribe: (req: number): number => {
+ if (stopped) return 0;
+ const reqData = getReq(req);
+ handles.delete(req); // consume the req handle
+
+ const subHandle = allocHandle("sub", {
+ rxSubscription: null,
+ filter: reqData.filter,
+ relays: [],
+ eventCount: 0,
+ eosed: false,
+ });
+ trace(
+ "program",
+ "subscribe",
+ {
+ req,
+ filter: reqData.filter,
+ relayHints: reqData.relays,
+ closeOnEose: reqData.closeOnEose,
+ },
+ { sub: subHandle },
+ );
+
+ const on_event = instance!.exports.on_event as (
+ sub: number,
+ ev: number,
+ eosed: number,
+ ) => void;
+ const on_eose = instance!.exports.on_eose as (sub: number) => void;
+
+ (async () => {
+ let relays: string[] = reqData.relays;
+
+ if (!relays.length) {
+ try {
+ const result = await selectRelaysForFilter(
+ globalEventStore,
+ reqData.filter,
+ );
+ relays = result.relays;
+ } catch {
+ relays = [...AGGREGATOR_RELAYS];
+ }
+ }
+
+ trace("host", "subscribe:connected", { sub: subHandle, relays });
+
+ if (!relays.length || stopped || !handles.has(subHandle)) return;
+
+ // Update sub data with resolved relays
+ const subEntry = handles.get(subHandle);
+ if (subEntry?.type === "sub") {
+ subEntry.data.relays = relays;
+ notifySubsChanged();
+ }
+
+ let eosed = false;
+
+ const observable = privatePool.subscription(relays, [reqData.filter]);
+ const rxSub = observable.subscribe({
+ next: (response) => {
+ if (stopped || !handles.has(subHandle)) return;
+
+ if (typeof response === "string" && response === "EOSE") {
+ eosed = true;
+ const se = handles.get(subHandle);
+ if (se?.type === "sub") se.data.eosed = true;
+ trace("host", "on_eose", { sub: subHandle });
+ on_eose(subHandle);
+
+ if (reqData.closeOnEose) {
+ const entry = handles.get(subHandle);
+ if (entry?.type === "sub") {
+ entry.data.rxSubscription?.unsubscribe();
+ closedSubs.push(snapshotSub(subHandle, entry.data, true));
+ }
+ handles.delete(subHandle);
+ notifySubsChanged();
+ trace("host", "sub:closed_on_eose", { sub: subHandle });
+ }
+ } else if (isNostrEvent(response)) {
+ privateEventStore.add(response);
+ totalEventsReceived++;
+ const se2 = handles.get(subHandle);
+ if (se2?.type === "sub") se2.data.eventCount++;
+ options.onEventCount?.(totalEventsReceived);
+ notifySubsChanged();
+ const evHandle = allocHandle("event", response);
+ trace("host", "on_event", {
+ sub: subHandle,
+ ev: evHandle,
+ kind: response.kind,
+ id: response.id,
+ pubkey: response.pubkey,
+ eosed: eosed ? 1 : 0,
+ });
+ on_event(subHandle, evHandle, eosed ? 1 : 0);
+ }
+ },
+ error: (err) => {
+ trace("host", "sub:error", {
+ sub: subHandle,
+ error: String(err),
+ });
+ const entry = handles.get(subHandle);
+ if (entry?.type === "sub") {
+ closedSubs.push(snapshotSub(subHandle, entry.data, true));
+ }
+ handles.delete(subHandle);
+ notifySubsChanged();
+ },
+ });
+
+ const entry = handles.get(subHandle);
+ if (entry?.type === "sub") {
+ entry.data.rxSubscription = rxSub;
+ } else {
+ rxSub.unsubscribe();
+ }
+ })();
+
+ return subHandle;
+ },
+
+ // --- Event accessors ---
+
+ event_get_id: (ev: number): number => {
+ if (stopped) return 0;
+ const e = getEvent(ev);
+ trace("program", "event_get_id", { ev }, { id: e.id });
+ return writeHostBuf(hexToBytes(e.id), false);
+ },
+
+ event_get_id_hex: (ev: number): number => {
+ if (stopped) return 0;
+ const e = getEvent(ev);
+ trace("program", "event_get_id_hex", { ev }, { id: e.id });
+ return writeHostStr(e.id, false);
+ },
+
+ event_get_pubkey: (ev: number): number => {
+ if (stopped) return 0;
+ const e = getEvent(ev);
+ trace("program", "event_get_pubkey", { ev }, { pubkey: e.pubkey });
+ return writeHostBuf(hexToBytes(e.pubkey), false);
+ },
+
+ event_get_pubkey_hex: (ev: number): number => {
+ if (stopped) return 0;
+ const e = getEvent(ev);
+ trace("program", "event_get_pubkey_hex", { ev }, { pubkey: e.pubkey });
+ return writeHostStr(e.pubkey, false);
+ },
+
+ event_get_kind: (ev: number): number => {
+ if (stopped) return 0;
+ const kind = getEvent(ev).kind;
+ trace("program", "event_get_kind", { ev }, { kind });
+ return kind;
+ },
+
+ event_get_created_at: (ev: number): number => {
+ if (stopped) return 0;
+ const created_at = getEvent(ev).created_at;
+ trace("program", "event_get_created_at", { ev }, { created_at });
+ return created_at;
+ },
+
+ event_get_content: (ev: number): number => {
+ if (stopped) return 0;
+ const content = getEvent(ev).content;
+ trace("program", "event_get_content", { ev }, { content });
+ return writeHostStr(content);
+ },
+
+ event_get_tag_count: (ev: number): number => {
+ if (stopped) return 0;
+ const count = (getEvent(ev).tags ?? []).length;
+ trace("program", "event_get_tag_count", { ev }, { count });
+ return count;
+ },
+
+ event_get_tag_item_count: (ev: number, ti: number): number => {
+ if (stopped) return 0;
+ const count = (getEvent(ev).tags?.[ti] ?? []).length;
+ trace(
+ "program",
+ "event_get_tag_item_count",
+ { ev, tagIndex: ti },
+ { count },
+ );
+ return count;
+ },
+
+ event_get_tag_item: (ev: number, ti: number, ii: number): number => {
+ if (stopped) return 0;
+ const val = getEvent(ev).tags?.[ti]?.[ii];
+ trace(
+ "program",
+ "event_get_tag_item",
+ { ev, tagIndex: ti, itemIndex: ii },
+ { value: val ?? null },
+ );
+ return val != null ? writeHostStr(val) : 0;
+ },
+
+ event_get_tag_item_bin32: (ev: number, ti: number, ii: number): number => {
+ if (stopped) return 0;
+ const val = getEvent(ev).tags?.[ti]?.[ii];
+ if (typeof val !== "string" || val.length !== 64) {
+ trace(
+ "program",
+ "event_get_tag_item_bin32",
+ { ev, tagIndex: ti, itemIndex: ii },
+ { value: null },
+ );
+ return 0;
+ }
+ try {
+ trace(
+ "program",
+ "event_get_tag_item_bin32",
+ { ev, tagIndex: ti, itemIndex: ii },
+ { value: val },
+ );
+ return writeHostBuf(hexToBytes(val), false);
+ } catch {
+ return 0;
+ }
+ },
+
+ event_get_tag_item_by_name: (
+ ev: number,
+ nPtr: number,
+ nLen: number,
+ ii: number,
+ ): number => {
+ if (stopped) return 0;
+ const name = readStr(nPtr, nLen);
+ const tag = (getEvent(ev).tags ?? []).find((t) => t[0] === name);
+ const val = tag?.[ii];
+ trace(
+ "program",
+ "event_get_tag_item_by_name",
+ { ev, name, itemIndex: ii },
+ { value: val ?? null },
+ );
+ return val != null ? writeHostStr(val) : 0;
+ },
+
+ event_get_tag_item_by_name_bin32: (
+ ev: number,
+ nPtr: number,
+ nLen: number,
+ ii: number,
+ ): number => {
+ if (stopped) return 0;
+ const name = readStr(nPtr, nLen);
+ const tag = (getEvent(ev).tags ?? []).find((t) => t[0] === name);
+ const val = tag?.[ii];
+ if (typeof val !== "string" || val.length !== 64) {
+ trace(
+ "program",
+ "event_get_tag_item_by_name_bin32",
+ { ev, name, itemIndex: ii },
+ { value: null },
+ );
+ return 0;
+ }
+ try {
+ trace(
+ "program",
+ "event_get_tag_item_by_name_bin32",
+ { ev, name, itemIndex: ii },
+ { value: val },
+ );
+ return writeHostBuf(hexToBytes(val), false);
+ } catch {
+ return 0;
+ }
+ },
+
+ // --- Display and logging ---
+
+ display: (ev: number): void => {
+ if (stopped) return;
+ const event = getEvent(ev);
+ trace("program", "display", {
+ ev,
+ kind: event.kind,
+ id: event.id,
+ pubkey: event.pubkey,
+ created_at: event.created_at,
+ });
+ options.onDisplay(event);
+ },
+
+ log: (ptr: number, len: number): void => {
+ if (stopped) return;
+ const msg = readStr(ptr, len);
+ trace("program", "log", { message: msg });
+ options.onLog(msg);
+ },
+
+ drop: (h: number): void => {
+ if (stopped) return;
+ const entry = handles.get(h);
+ trace("program", "drop", { handle: h, type: entry?.type ?? "unknown" });
+ dropHandle(h);
+ },
+ };
+
+ // --- Param encoding ---
+
+ function encodeParams(): number {
+ if (paramSpecs.length === 0) return 0;
+
+ // First pass: calculate buffer size
+ let bufSize = usePresenceBytes ? paramSpecs.length : 0;
+
+ // All event params must already be resolved to NostrEvent objects by the caller.
+ const resolvedValues: (ParamValue | null)[] = [];
+
+ for (const spec of paramSpecs) {
+ const value = options.paramValues.get(spec.name) ?? null;
+
+ if (value === null) {
+ resolvedValues.push(null);
+ trace("host", "param:encode", {
+ name: spec.name,
+ type: spec.type,
+ present: false,
+ });
+ continue;
+ }
+
+ resolvedValues.push(value);
+ trace("host", "param:encode", {
+ name: spec.name,
+ type: spec.type,
+ value:
+ value instanceof Uint8Array
+ ? bytesToHex(value)
+ : typeof value === "object" && "id" in value
+ ? {
+ id: (value as NostrEvent).id,
+ kind: (value as NostrEvent).kind,
+ }
+ : value,
+ });
+
+ switch (spec.type) {
+ case "public_key":
+ bufSize += 32;
+ break;
+ case "event":
+ bufSize += 4;
+ break;
+ case "string":
+ case "relay": {
+ const encoded = textEncoder.encode(value as string);
+ bufSize += 4 + encoded.length;
+ break;
+ }
+ case "number":
+ case "timestamp":
+ bufSize += 4;
+ break;
+ }
+ }
+
+ const ptr = alloc(bufSize);
+ const view = new DataView(memory.buffer, ptr, bufSize);
+ let offset = 0;
+
+ for (const value of resolvedValues) {
+ if (usePresenceBytes) {
+ view.setUint8(offset, value === null ? 0 : 1);
+ offset += 1;
+ }
+ if (value === null) continue;
+
+ if (typeof value === "number") {
+ view.setInt32(offset, value, littleEndian);
+ offset += 4;
+ } else if (typeof value === "string") {
+ const encoded = textEncoder.encode(value);
+ view.setInt32(offset, encoded.length, littleEndian);
+ offset += 4;
+ new Uint8Array(memory.buffer, ptr + offset, encoded.length).set(
+ encoded,
+ );
+ offset += encoded.length;
+ } else if ("id" in value && "kind" in value) {
+ const evHandle = allocHandle("event", value as NostrEvent);
+ view.setInt32(offset, evHandle, littleEndian);
+ offset += 4;
+ } else if (value instanceof Uint8Array) {
+ new Uint8Array(memory.buffer, ptr + offset, value.length).set(value);
+ offset += value.length;
+ }
+ }
+
+ // Trace the raw encoded buffer
+ trace("host", "params:encoded", {
+ ptr,
+ size: bufSize,
+ hex: bytesToHex(new Uint8Array(memory.buffer, ptr, bufSize)),
+ });
+
+ return ptr;
+ }
+
+ function cleanup(): void {
+ stopped = true;
+ for (const [h] of handles) {
+ dropHandle(h);
+ }
+ handles.clear();
+ // Force-close all private relay connections
+ for (const [, relay] of privatePool.relays) {
+ try {
+ relay.close();
+ } catch {
+ /* ignore */
+ }
+ }
+ instance = null;
+ }
+
+ function stop(): void {
+ if (stopped) return;
+ cleanup();
+ setState("stopped");
+ }
+
+ try {
+ setState("loading");
+
+ const binaryStr = atob(wasmBase64);
+ const wasmBytes = new Uint8Array(binaryStr.length);
+ for (let i = 0; i < binaryStr.length; i++) {
+ wasmBytes[i] = binaryStr.charCodeAt(i);
+ }
+
+ const wasm = await WebAssembly.instantiate(wasmBytes.buffer, {
+ env: {
+ memory: new WebAssembly.Memory({ initial: 1 }),
+ __memory_base: 0,
+ __table_base: 0,
+ abort() {
+ options.onLog("[scroll] abort() called");
+ stop();
+ },
+ },
+ nostr: nostrImports,
+ });
+
+ instance = wasm.instance;
+ memory = instance.exports.memory as WebAssembly.Memory;
+
+ const paramsPtr = encodeParams();
+ setState("running");
+ (instance.exports.run as (ptr: number) => void)(paramsPtr);
+ } catch (err) {
+ const error = err instanceof Error ? err : new Error(String(err));
+ options.onLog(`Error: ${error.message}`);
+ options.onError?.(error);
+ cleanup();
+ setState("error");
+ }
+
+ return { stop };
+}
diff --git a/src/services/relay-selection.ts b/src/services/relay-selection.ts
index a0755bc..82e27dd 100644
--- a/src/services/relay-selection.ts
+++ b/src/services/relay-selection.ts
@@ -86,6 +86,38 @@ function sanitizeRelays(relays: string[]): string[] {
});
}
+/**
+ * Derives author pubkeys from event references in a filter.
+ * Looks up events by ids, #e tags, and #a tags in the EventStore
+ * to extract their author pubkeys for relay selection.
+ */
+function deriveAuthorsFromEventRefs(
+ store: IEventStore,
+ filter: NostrFilter,
+): string[] {
+ const pubkeys = new Set();
+
+ // From ids filter — look up events directly
+ for (const id of filter.ids || []) {
+ const event = store.getEvent(id);
+ if (event) pubkeys.add(event.pubkey);
+ }
+
+ // From #e tag filter — same lookup
+ for (const id of filter["#e"] || []) {
+ const event = store.getEvent(id);
+ if (event) pubkeys.add(event.pubkey);
+ }
+
+ // From #a tag filter — parse as replaceable address
+ for (const addr of filter["#a"] || []) {
+ const parsed = parseReplaceableAddress(addr);
+ if (parsed) pubkeys.add(parsed.pubkey);
+ }
+
+ return [...pubkeys];
+}
+
/**
* Gets outbox (write) relays for a pubkey
* Checks cache first, falls back to EventStore
@@ -388,15 +420,23 @@ export async function selectRelaysForFilter(
} = options;
// Extract pubkeys from filter
- const authors = filter.authors || [];
+ let authors = filter.authors || [];
const pTags = filter["#p"] || [];
- // If no pubkeys, return fallback immediately
+ // If no authors or p-tags, try to derive authors from referenced events
if (authors.length === 0 && pTags.length === 0) {
- console.debug(
- "[RelaySelection] No authors or #p tags, using fallback relays",
- );
- return createFallbackResult(fallbackRelays);
+ const derivedAuthors = deriveAuthorsFromEventRefs(eventStore, filter);
+ if (derivedAuthors.length > 0) {
+ console.debug(
+ `[RelaySelection] Derived ${derivedAuthors.length} authors from event refs`,
+ );
+ authors = derivedAuthors;
+ } else {
+ console.debug(
+ "[RelaySelection] No authors, #p tags, or event refs, using fallback relays",
+ );
+ return createFallbackResult(fallbackRelays);
+ }
}
console.debug(