feat: nip-5c scrolls (WASM programs)

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
This commit is contained in:
Alejandro Gómez
2026-04-09 16:32:58 +02:00
parent ca908f0e70
commit 6466577f70
12 changed files with 2062 additions and 7 deletions

View File

@@ -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 (
<div className="flex items-center gap-3 p-3 border border-border/50 rounded">
<Skeleton className="h-4 w-4 rounded" />
<div className="flex-1">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-3 w-48 mt-1" />
</div>
</div>
);
}
const name = getScrollName(scrollEvent);
const iconUrl = getScrollIcon(scrollEvent);
const params = getScrollParams(scrollEvent);
const contentSize = getScrollContentSize(scrollEvent);
return (
<div className="flex items-center gap-3 p-3 border border-border/50 rounded group hover:bg-muted/30 transition-colors">
<ScrollIconImage iconUrl={iconUrl} className="size-4" />
<div className="flex-1 min-w-0">
<div className="text-sm font-medium truncate">
{name || "Unnamed Scroll"}
</div>
<div className="text-xs text-muted-foreground mt-0.5">
{params.length > 0 && (
<span>
{params.length} param{params.length !== 1 ? "s" : ""}
</span>
)}
{params.length > 0 && contentSize > 0 && <span> · </span>}
{contentSize > 0 && <span>{formatBytes(contentSize)}</span>}
</div>
</div>
{canModify && onUnfavorite && (
<button
onClick={() => onUnfavorite(scrollEvent)}
className="p-1.5 text-muted-foreground hover:text-yellow-500 transition-colors flex-shrink-0"
title="Remove from favorites"
>
<Star className="size-3.5 fill-current" />
</button>
)}
</div>
);
}
/**
* Kind 10027 Renderer - Favorite Scrolls (Feed View)
*/
export function FavoriteScrollsRenderer({ event }: BaseEventProps) {
const pointers = getListPointers(event, "e");
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<ScrollText className="size-4 text-muted-foreground" />
<span>Favorite Scrolls</span>
</ClickableEventTitle>
<div className="text-xs text-muted-foreground">
{pointers.length === 0
? "No favorite scrolls"
: `${pointers.length} favorite scroll${pointers.length !== 1 ? "s" : ""}`}
</div>
</div>
</BaseEventContainer>
);
}
/**
* 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 (
<div className="flex flex-col gap-6 p-4">
<div className="flex items-center gap-2">
<ScrollText className="size-6 text-muted-foreground" />
<span className="text-lg font-semibold">Favorite Scrolls</span>
<span className="text-sm text-muted-foreground">
({pointers.length})
</span>
</div>
{pointers.length === 0 ? (
<div className="text-sm text-muted-foreground italic">
No favorite scrolls yet. Star a scroll to add it here.
</div>
) : (
<div className="flex flex-col gap-2">
{pointers.map((pointer) => (
<ScrollRefItem
key={pointer.id}
pointer={pointer}
onUnfavorite={canSign ? toggleFavorite : undefined}
canModify={canSign}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -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 <ScrollText className={`${className} text-muted-foreground`} />;
}
return (
<img
src={iconUrl}
alt=""
className={`${className} object-contain rounded-sm`}
onError={() => 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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
<ClickableEventTitle
event={event}
className="flex items-center gap-1.5 text-sm font-medium"
>
<ScrollIconImage iconUrl={iconUrl} className="size-4" />
<span>{name || "Unnamed Scroll"}</span>
</ClickableEventTitle>
{description && (
<p className="text-xs text-muted-foreground line-clamp-2">
{description}
</p>
)}
{params.length > 0 && (
<div className="flex flex-col gap-0.5 text-xs text-muted-foreground">
{params.map((param) => {
const { icon: Icon } = PARAM_CONFIG[param.type];
return (
<div key={param.name} className="flex items-center gap-1.5">
<Icon className="size-3 flex-shrink-0" />
<span>{param.name}</span>
</div>
);
})}
</div>
)}
{contentSize > 0 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Binary className="size-3.5" />
<span>{formatBytes(contentSize)}</span>
</div>
)}
</div>
</BaseEventContainer>
);
}
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 (
<div className="flex flex-col gap-4 p-4 h-full">
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<ScrollIconImage iconUrl={iconUrl} className="size-5" />
<h2 className="text-lg font-semibold">{name || "Unnamed Scroll"}</h2>
</div>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
{contentSize > 0 && (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Binary className="size-3.5" />
<span>~{formatBytes(contentSize)} WASM</span>
</div>
)}
</div>
<ScrollExecutor params={params} wasmBase64={event.content} />
</div>
);
}

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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<number, React.ComponentType<BaseEventProps>> = {
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)

View File

@@ -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 (
<div className="flex items-center gap-2">
<Button size="sm" onClick={onRun} disabled={!canRun || runDisabled}>
{isActive ? (
<Loader2 className="size-3.5 animate-spin" />
) : (
<Play className="size-3.5" />
)}
Run
</Button>
<Button
size="sm"
variant="destructive"
onClick={onStop}
disabled={!isActive}
>
<Square className="size-3.5" />
Stop
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="size-8 ml-auto"
disabled={isActive}
>
<Settings className="size-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Encoding</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuRadioGroup
value={endianness}
onValueChange={(v) => onEndiannessChange(v as "LE" | "BE")}
>
<DropdownMenuRadioItem value="LE">
Little-endian (spec)
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="BE">
Big-endian (legacy)
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
<DropdownMenuSeparator />
<DropdownMenuCheckboxItem
checked={presenceBytes}
onCheckedChange={(v) => onPresenceBytesChange(v === true)}
>
Presence bytes
</DropdownMenuCheckboxItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -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<string, string> = {};
for (const p of params) {
if (p.name === "me" && p.type === "public_key" && pubkey) {
defaultValues[p.name] = pubkey;
}
}
const [runtimeState, setRuntimeState] = useState<ScrollRuntimeState>("idle");
const [paramValues, setParamValues] =
useState<Record<string, string>>(defaultValues);
const [displayedEventsMap, setDisplayedEventsMap] = useState<
Map<string, NostrEvent>
>(new Map());
const [logEntries, setLogEntries] = useState<string[]>([]);
const [traceEntries, setTraceEntries] = useState<TraceEntry[]>([]);
const [activeSubs, setActiveSubs] = useState<SubscriptionInfo[]>([]);
const [eventCount, setEventCount] = useState(0);
const controllerRef = useRef<ScrollRuntimeController | null>(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<string, ParamValue>();
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 (
<div className="flex flex-col gap-4 flex-1 min-h-0">
<ScrollParamForm
params={params}
values={paramValues}
onChange={setParamValues}
connectedRelays={connectedRelays}
disabled={isActive}
/>
<ScrollControls
runtimeState={runtimeState}
onRun={handleRun}
onStop={handleStop}
runDisabled={requiredParamsMissing}
endianness={endianness}
presenceBytes={presenceBytes}
onEndiannessChange={setEndianness}
onPresenceBytesChange={setPresenceBytes}
/>
<ScrollOutput
displayedEvents={displayedEvents}
logEntries={logEntries}
traceEntries={traceEntries}
activeSubs={activeSubs}
eventCount={eventCount}
isActive={isActive}
/>
</div>
);
}

View File

@@ -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 (
<div className="border-b border-border/50">
<div
className={`flex items-center gap-1.5 text-xs font-mono px-2 py-1 ${hasDetail ? "cursor-pointer hover:bg-muted/30" : ""}`}
onClick={() => hasDetail && setExpanded(!expanded)}
>
{hasDetail ? (
expanded ? (
<ChevronDown className="size-3 flex-shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="size-3 flex-shrink-0 text-muted-foreground" />
)
) : (
<span className="w-3 flex-shrink-0" />
)}
<span
className={`font-semibold ${entry.direction === "program" ? "text-highlight" : "text-accent"}`}
>
{entry.direction === "program" ? "program" : "host"}
</span>
<span className="text-foreground">{entry.fn}</span>
</div>
{expanded && hasDetail && (
<div className="pl-7 pr-2 pb-2">
<CopyableJsonViewer
json={JSON.stringify(
{
...(entry.args && { args: entry.args }),
...(entry.result && { result: entry.result }),
},
null,
2,
)}
/>
</div>
)}
</div>
);
}
function EmptyState({ message }: { message: string }) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
<p className="text-xs">{message}</p>
</div>
);
}
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 (
<Tabs defaultValue="results" className="flex flex-col flex-1 min-h-0">
<div className="flex items-center border-b border-border">
<TabsList className="h-auto bg-transparent p-0 rounded-none">
<TabsTrigger
value="results"
className="gap-2 rounded-none border-b-2 border-transparent text-xs data-[state=active]:border-foreground data-[state=active]:shadow-none"
>
<List className="size-3" />
Results
</TabsTrigger>
<TabsTrigger
value="logs"
className="gap-2 rounded-none border-b-2 border-transparent text-xs data-[state=active]:border-foreground data-[state=active]:shadow-none"
>
<Terminal className="size-3" />
Logs
</TabsTrigger>
<TabsTrigger
value="subs"
className="gap-2 rounded-none border-b-2 border-transparent text-xs data-[state=active]:border-foreground data-[state=active]:shadow-none"
>
<Wifi className="size-3" />
Subs
</TabsTrigger>
<TabsTrigger
value="trace"
className="gap-2 rounded-none border-b-2 border-transparent text-xs data-[state=active]:border-foreground data-[state=active]:shadow-none"
>
<Activity className="size-3" />
Trace
</TabsTrigger>
</TabsList>
</div>
<TabsContent
value="results"
className="flex-1 min-h-0 mt-0 flex flex-col"
>
<div className="flex items-center px-2 py-1 border-b border-border text-xs text-muted-foreground">
<div className="flex items-center gap-3 ml-auto">
<span className="flex items-center gap-1">
<Wifi className="size-3" />
{openSubsCount}
</span>
<span className="flex items-center gap-1">
<FileText className="size-3" />
{eventCount}
</span>
<span className="flex items-center gap-1">
<List className="size-3" />
{displayedEvents.length}
</span>
<button
onClick={() => setCompact((v) => !v)}
className="flex items-center text-muted-foreground hover:text-foreground transition-colors"
title={compact ? "Switch to list view" : "Switch to compact view"}
>
{compact ? (
<GalleryVertical className="size-3" />
) : (
<List className="size-3" />
)}
</button>
</div>
</div>
<div className="flex-1 min-h-0">
{displayedEvents.length === 0 ? (
<EmptyState
message={
isActive
? "Waiting for results..."
: "Run the scroll to see results"
}
/>
) : (
<Virtuoso
style={{ height: "100%" }}
data={displayedEvents}
computeItemKey={(_index, ev) => ev.id}
itemContent={(_index, ev) =>
compact ? (
<MemoizedCompactEventRow event={ev} />
) : (
<MemoizedFeedEvent event={ev} />
)
}
/>
)}
</div>
</TabsContent>
<TabsContent value="logs" className="flex-1 min-h-0 mt-0">
{logEntries.length === 0 ? (
<EmptyState
message={isActive ? "Waiting for logs..." : "No log output"}
/>
) : (
<Virtuoso
style={{ height: "100%" }}
data={logEntries}
followOutput="smooth"
computeItemKey={(index) => index}
itemContent={(_index, entry) => (
<div className="text-xs font-mono px-2 py-0.5 border-b border-border/50 whitespace-pre-wrap break-all">
{entry}
</div>
)}
/>
)}
</TabsContent>
<TabsContent value="subs" className="flex-1 min-h-0 mt-0">
{activeSubs.length === 0 ? (
<EmptyState
message={
isActive
? "No active subscriptions"
: "Run the scroll to see subscriptions"
}
/>
) : (
<div className="overflow-auto h-full p-2 flex flex-col gap-2">
{[...activeSubs]
.sort((a, b) => a.handle - b.handle)
.map((sub) => (
<div
key={sub.handle}
className="border border-border/50 rounded p-3 flex flex-col gap-2"
>
<div className="flex items-center gap-2 text-xs">
<span className="font-mono font-semibold">
SUB #{sub.handle}
</span>
<Label size="sm">{sub.eventCount} events</Label>
{sub.eosed && <Label size="sm">EOSE</Label>}
<span
className={`ml-auto flex items-center gap-1 ${sub.closed ? "text-muted-foreground" : "text-green-400"}`}
>
<span
className={`size-1.5 rounded-full ${sub.closed ? "bg-muted-foreground" : "bg-green-400"}`}
/>
{sub.closed ? "closed" : "open"}
</span>
</div>
<div className="text-xs text-muted-foreground">
<span className="font-medium">Filter:</span>
<pre className="mt-1 text-[11px] font-mono bg-muted/30 rounded p-1.5 overflow-x-auto">
{JSON.stringify(sub.filter, null, 2)}
</pre>
</div>
{sub.relays.length > 0 && (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium text-muted-foreground">
Relays ({sub.relays.length}):
</span>
<div className="flex flex-col gap-0.5">
{sub.relays.map((url) => (
<RelayLink key={url} url={url} />
))}
</div>
</div>
)}
</div>
))}
</div>
)}
</TabsContent>
<TabsContent value="trace" className="flex-1 min-h-0 mt-0">
{traceEntries.length === 0 ? (
<EmptyState
message={isActive ? "Waiting for trace data..." : "No trace data"}
/>
) : (
<Virtuoso
style={{ height: "100%" }}
data={traceEntries}
followOutput="smooth"
computeItemKey={(index) => index}
itemContent={(_index, entry) => <TraceRow entry={entry} />}
/>
)}
</TabsContent>
</Tabs>
);
}

View File

@@ -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<string, string>;
onChange: (values: Record<string, string>) => 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 (
<div className="flex flex-col gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Settings className="size-4" />
<span>Parameters</span>
</div>
<div className="flex flex-col gap-1.5">
{params.map((param) => {
const {
icon: Icon,
placeholder,
inputType,
} = PARAM_CONFIG[param.type];
return (
<div
key={param.name}
className="flex flex-col gap-1 px-3 py-2 border border-border/50 rounded"
>
<div className="flex items-center gap-2">
<Icon className="size-3.5 text-muted-foreground flex-shrink-0" />
<span className="text-sm font-medium font-mono">
{param.name}
</span>
<Label size="sm">{param.type}</Label>
{param.required && <Label size="sm">required</Label>}
{param.supportedKinds && (
<span className="text-xs text-muted-foreground">
kinds: {param.supportedKinds}
</span>
)}
</div>
{param.description && (
<p className="text-xs text-muted-foreground">
{param.description}
</p>
)}
{param.type === "relay" ? (
<div className="flex gap-1.5">
<select
value={values[param.name] || ""}
onChange={(e) => setValue(param.name, e.target.value)}
disabled={disabled}
className="flex-1 h-8 rounded-md border border-input bg-transparent px-2 text-xs focus:outline-none focus:ring-1 focus:ring-ring"
>
<option value="">Select relay...</option>
{connectedRelays.map((url) => (
<option key={url} value={url}>
{url}
</option>
))}
</select>
<Input
type="text"
placeholder="or type wss://..."
value={
connectedRelays.includes(values[param.name] || "")
? ""
: values[param.name] || ""
}
onChange={(e) => setValue(param.name, e.target.value)}
disabled={disabled}
className="flex-1 h-8 text-xs"
/>
</div>
) : (
<Input
type={inputType}
placeholder={placeholder}
value={values[param.name] || ""}
onChange={(e) => setValue(param.name, e.target.value)}
disabled={disabled}
className="h-8 text-xs"
/>
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -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<number, FavoriteListConfig> = {
elementKind: 30030,
label: "Emoji Sets",
},
[SCROLL_KIND]: {
listKind: 10027,
elementKind: SCROLL_KIND,
label: "Favorite Scrolls",
},
39000: {
listKind: 10009,
elementKind: 39000,

View File

@@ -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<number | string, EventKind> = {
// Core protocol kinds
@@ -473,6 +475,13 @@ export const EVENT_KINDS: Record<number | string, EventKind> = {
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<number | string, EventKind> = {
nip: "51",
icon: Play,
},
10027: {
kind: 10027,
name: "Favorite Scrolls",
description: "Favorite scrolls list",
nip: "5C",
icon: ScrollText,
},
10030: {
kind: 10030,
name: "Emoji List",

135
src/lib/nip5c-helpers.ts Normal file
View File

@@ -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<string>([
"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;
}
}

862
src/lib/scroll-runtime.ts Normal file
View File

@@ -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<NostrEvent | undefined> {
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<string, unknown>;
result?: Record<string, unknown>;
timestamp: number;
}
export interface ScrollRuntimeOptions {
paramValues: Map<string, ParamValue>;
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<T>(arr: T[], value: T): void {
if (!arr.includes(value)) arr.push(value);
}
export async function runScroll(
wasmBase64: string,
paramSpecs: ScrollParam[],
options: ScrollRuntimeOptions,
): Promise<ScrollRuntimeController> {
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<number, HandleEntry>();
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<string, unknown>,
result?: Record<string, unknown>,
): 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<string, string[]>)[key] || [];
pushUnique(tags, value);
(r.filter as Record<string, string[]>)[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<string, string[]>)[key] || [];
pushUnique(tags, value);
(r.filter as Record<string, string[]>)[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 };
}

View File

@@ -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<string>();
// 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(