From 6466577f705e790c8aa2c9e29d38d8e3c4cb073b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20G=C3=B3mez?= Date: Thu, 9 Apr 2026 16:32:58 +0200 Subject: [PATCH] feat: nip-5c scrolls (WASM programs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for NIP-5C Scrolls — self-contained WebAssembly programs published as Nostr events. Includes kind registration (1227, 10027), feed/detail renderers, favorite scrolls list, a full WASM host runtime, and an interactive executor UI with param input, execution controls, and tabbed output (results, logs, subs, trace). - Kind 1227 (Scroll) and 10027 (Favorite Scrolls) registered - WASM host runtime with complete nostr.* import API - Dedicated RelayPool + EventStore per execution for isolation - Relay selection enhanced to derive authors from event refs - Configurable encoding (LE/BE endianness, presence bytes) - Extracted reusable scroll executor components --- .../nostr/kinds/FavoriteScrollsRenderer.tsx | 153 ++++ src/components/nostr/kinds/ScrollRenderer.tsx | 154 ++++ src/components/nostr/kinds/index.tsx | 9 + src/components/scroll/ScrollControls.tsx | 99 ++ src/components/scroll/ScrollExecutor.tsx | 188 ++++ src/components/scroll/ScrollOutput.tsx | 285 ++++++ src/components/scroll/ScrollParamForm.tsx | 109 +++ src/config/favorite-lists.ts | 7 +- src/constants/kinds.ts | 16 + src/lib/nip5c-helpers.ts | 135 +++ src/lib/scroll-runtime.ts | 862 ++++++++++++++++++ src/services/relay-selection.ts | 52 +- 12 files changed, 2062 insertions(+), 7 deletions(-) create mode 100644 src/components/nostr/kinds/FavoriteScrollsRenderer.tsx create mode 100644 src/components/nostr/kinds/ScrollRenderer.tsx create mode 100644 src/components/scroll/ScrollControls.tsx create mode 100644 src/components/scroll/ScrollExecutor.tsx create mode 100644 src/components/scroll/ScrollOutput.tsx create mode 100644 src/components/scroll/ScrollParamForm.tsx create mode 100644 src/lib/nip5c-helpers.ts create mode 100644 src/lib/scroll-runtime.ts 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 ( +
+

{message}

+
+ ); +} + +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(