Merge pull request 'feat(activities): organizer ticket scanner over Nostr transport' (#73) from ticket-scanner-nostr-webapp into dev

Reviewed-on: #73
This commit is contained in:
padreug 2026-05-24 16:51:12 +00:00
commit 815bc2d15f
15 changed files with 684 additions and 7 deletions

View file

@ -11,6 +11,13 @@ VITE_API_KEY=your-api-key-here
VITE_LNBITS_DEBUG=false
VITE_WEBSOCKET_ENABLED=true
# LNbits Nostr-transport server pubkey (kind-21000 RPC endpoint).
# Logged by the LNbits server at startup:
# `Nostr transport: starting with pubkey <hex>... on N relay(s)`
# Required for the activities ticket scanner; legacy HTTP path still
# works without it.
VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY=
# Lightning Address Domain (optional)
# Override the domain used for Lightning Addresses
# If not set, domain will be extracted from VITE_LNBITS_BASE_URL

View file

@ -149,6 +149,9 @@ export const SERVICE_TOKENS = {
// Nostr metadata services
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
// Nostr transport (kind-21000 RPC over relays — LNbits backend)
NOSTR_TRANSPORT_SERVICE: Symbol('nostrTransportService'),
// Activities services (Nostr-native events + ticketing module)
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'),

View file

@ -58,6 +58,7 @@ const messages: LocaleMessages = {
thisWeek: 'This Week',
thisMonth: 'This Month',
myTickets: 'My tickets',
hosting: 'Hosting',
},
categories: {
concert: 'Concert',

View file

@ -58,6 +58,7 @@ const messages: LocaleMessages = {
thisWeek: 'Esta semana',
thisMonth: 'Este mes',
myTickets: 'Mis boletos',
hosting: 'Organizo',
},
categories: {
concert: 'Concierto',

View file

@ -58,6 +58,7 @@ const messages: LocaleMessages = {
thisWeek: 'Cette semaine',
thisMonth: 'Ce mois-ci',
myTickets: 'Mes billets',
hosting: 'J\'organise',
},
categories: {
concert: 'Concert',

View file

@ -59,6 +59,7 @@ export interface LocaleMessages {
thisWeek: string
thisMonth: string
myTickets: string
hosting: string
}
categories: Record<string, string>
detail: {

View file

@ -4,6 +4,12 @@ export const LNBITS_CONFIG = {
// This should point to your LNBits instance
API_BASE_URL: `${import.meta.env.VITE_LNBITS_BASE_URL || ''}/api/v1`,
// LNbits Nostr-transport server pubkey. The webapp encrypts its
// signed kind-21000 RPC events to this pubkey and listens for
// signed responses from it. Logged by the LNbits server at startup
// (`Nostr transport: starting with pubkey <hex>...`).
NOSTR_TRANSPORT_PUBKEY: import.meta.env.VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY || '',
// Whether to enable debug logging
DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true',

View file

@ -22,6 +22,13 @@ export function useActivityFilters() {
* (this composable stays free of ticket fetching).
*/
const onlyOwnedTickets = ref(false)
/**
* When true, the feed is narrowed to activities the current user
* is hosting (organizer pubkey matches the signed-in user, or the
* row is a local LNbits draft of theirs). Reads `activity.isMine`
* which `useActivities.tagOwnership()` populates.
*/
const onlyHosting = ref(false)
const filters = computed<ActivityFilters>(() => ({
temporal: temporal.value,
@ -54,6 +61,13 @@ export function useActivityFilters() {
)
}
// Hosting filter — activities the signed-in user organizes.
// Read off `activity.isMine` which `useActivities.tagOwnership()`
// populates from organizer-pubkey match + LNbits drafts.
if (onlyHosting.value) {
result = result.filter(a => a.isMine === true)
}
return result
}
@ -89,17 +103,23 @@ export function useActivityFilters() {
selectedCategories.value = []
selectedDate.value = undefined
onlyOwnedTickets.value = false
onlyHosting.value = false
}
function toggleOwnedTickets() {
onlyOwnedTickets.value = !onlyOwnedTickets.value
}
function toggleHosting() {
onlyHosting.value = !onlyHosting.value
}
const hasActiveFilters = computed(() =>
temporal.value !== 'all' ||
selectedCategories.value.length > 0 ||
selectedDate.value !== undefined ||
onlyOwnedTickets.value
onlyOwnedTickets.value ||
onlyHosting.value
)
return {
@ -108,6 +128,7 @@ export function useActivityFilters() {
selectedCategories,
selectedDate,
onlyOwnedTickets,
onlyHosting,
filters,
hasActiveFilters,
@ -118,6 +139,7 @@ export function useActivityFilters() {
toggleCategory,
clearCategories,
toggleOwnedTickets,
toggleHosting,
resetFilters,
}
}

View file

@ -0,0 +1,133 @@
import { ref, type Ref } from 'vue'
import { useLocalStorage } from '@vueuse/core'
import { injectService, SERVICE_TOKENS } from '@/core/di-container'
import type { NostrTransportService } from '@/modules/base/services/NostrTransportService'
export type ScanStatus = 'ok' | 'duplicate-session' | 'error'
export interface ScanResult {
status: ScanStatus
ticketId: string
/** Backend response payload on OK. */
ticket?: Record<string, unknown>
/** Error string from the backend or local validation. */
message?: string
}
export interface ScanRecord {
ticketId: string
/** Holder display name from the backend, if any. */
name?: string | null
registeredAt: string
}
/**
* Stateful scanner driver. Owns the camera lifecycle (delegated to
* useQRScanner upstream), the QR decode, the
* `events_ticket_register` RPC call, and a session-local scanned
* list persisted to localStorage so a page refresh doesn't ask the
* organizer to rescan tickets they already counted.
*
* Mirrors the LNbits admin Quasar register page's
* `events_scanned_<eventId>` localStorage key with the
* `activities_scanned_<id>` prefix.
*/
export function useTicketScanner(activityId: Ref<string>) {
const transport = injectService<NostrTransportService>(
SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE,
)
const isProcessing = ref(false)
const lastScan = ref<ScanResult | null>(null)
/**
* Set to `true` immediately after a decode resolves (success or
* failure) and stays true until the operator dismisses or taps
* "Scan next". While paused, further `onDecode` calls are dropped
* — the camera keeps streaming for instant resume but the result
* banner sticks so the door can confirm the outcome before
* moving on. Without this, the 5-fps decode loop instantly
* fires "already scanned this session" on the very ticket that
* just succeeded.
*/
const isPaused = ref(false)
const scanned = useLocalStorage<ScanRecord[]>(
() => `activities_scanned_${activityId.value}`,
[],
)
function parseTicketId(qrText: string): string {
return qrText.startsWith('ticket://')
? qrText.slice('ticket://'.length)
: qrText
}
async function onDecode(qrText: string): Promise<void> {
if (isProcessing.value || isPaused.value) return
const ticketId = parseTicketId(qrText).trim()
if (!ticketId) return
// Session-local de-dup. Distinct from the backend's "already
// registered" — this guards against the QR being held in front
// of the camera for multiple decode frames.
if (scanned.value.some(r => r.ticketId === ticketId)) {
lastScan.value = { status: 'duplicate-session', ticketId }
isPaused.value = true
return
}
isProcessing.value = true
try {
const ticket = await transport.call<Record<string, unknown>>(
'events_ticket_register',
{
event_id: activityId.value,
ticket_id: ticketId,
},
)
const name = (ticket?.name as string | null | undefined) ?? null
scanned.value = [
{ ticketId, name, registeredAt: new Date().toISOString() },
...scanned.value,
]
lastScan.value = { status: 'ok', ticketId, ticket }
} catch (e) {
// Backend RPC errors arrive as NostrRpcError with the
// string in `.message`: "Ticket not paid for", "Ticket
// already registered", "Ticket does not exist on this
// event", "You do not own this event", etc.
lastScan.value = {
status: 'error',
ticketId,
message: e instanceof Error ? e.message : String(e),
}
} finally {
isProcessing.value = false
// Pause the decode loop regardless of outcome. The operator
// taps "Scan next" to resume; this guarantees they see the
// banner and can correct (let the attendee in, deny entry,
// etc.) before the next QR comes into frame.
isPaused.value = true
}
}
function resume() {
lastScan.value = null
isPaused.value = false
}
function clearScanned() {
scanned.value = []
lastScan.value = null
isPaused.value = false
}
return {
isProcessing,
isPaused,
lastScan,
scanned,
onDecode,
resume,
clearScanned,
}
}

View file

@ -78,6 +78,15 @@ export const activitiesModule = createModulePlugin({
requiresAuth: true,
},
},
{
path: '/scan/:activityId',
name: 'scan-tickets',
component: () => import('./views/ScanTicketsPage.vue'),
meta: {
title: 'Scan Tickets',
requiresAuth: true,
},
},
{
path: '/events',
name: 'events',

View file

@ -8,7 +8,7 @@ import {
CollapsibleContent,
CollapsibleTrigger,
} from '@/components/ui/collapsible'
import { SlidersHorizontal, ChevronDown, Ticket } from 'lucide-vue-next'
import { SlidersHorizontal, ChevronDown, Ticket, Megaphone } from 'lucide-vue-next'
import { useActivities } from '../composables/useActivities'
import { useAuth } from '@/composables/useAuthService'
import ActivitySearchOverlay from '../components/ActivitySearchOverlay.vue'
@ -30,11 +30,13 @@ const {
hasActiveFilters,
selectedDate,
onlyOwnedTickets,
onlyHosting,
selectDate,
setTemporal,
toggleCategory,
clearCategories,
toggleOwnedTickets,
toggleHosting,
resetFilters,
subscribe,
} = useActivities()
@ -79,10 +81,10 @@ function handleSelectActivity(activity: Activity) {
<TemporalFilterBar :model-value="temporal" @update:model-value="setTemporal" />
</div>
<!-- "My tickets" filter chip — narrows the feed to activities
the user holds at least one paid ticket for. Hidden when
logged out (no tickets to filter on). -->
<div v-if="isAuthenticated" class="mb-4">
<!-- Role filter chips — narrow the feed to activities the user
has skin in. Hidden when logged out (nothing to filter on).
"My tickets" = attending; "Hosting" = organizing. -->
<div v-if="isAuthenticated" class="mb-4 flex flex-wrap gap-2">
<Button
:variant="onlyOwnedTickets ? 'default' : 'outline'"
size="sm"
@ -92,6 +94,15 @@ function handleSelectActivity(activity: Activity) {
<Ticket class="w-3.5 h-3.5" />
{{ t('activities.filters.myTickets', 'My tickets') }}
</Button>
<Button
:variant="onlyHosting ? 'default' : 'outline'"
size="sm"
class="gap-1.5"
@click="toggleHosting"
>
<Megaphone class="w-3.5 h-3.5" />
{{ t('activities.filters.hosting', 'Hosting') }}
</Button>
</div>
<!-- Category filters (collapsible) -->

View file

@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import {
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2,
Calendar, MapPin, ArrowLeft, Pencil, Ticket, CheckCircle2, ScanLine,
} from 'lucide-vue-next'
import { useActivityDetail } from '../composables/useActivityDetail'
import BookmarkButton from '../components/BookmarkButton.vue'
@ -64,6 +64,10 @@ function openEditDialog() {
activitiesStore.showCreateDialog = true
}
function openScannerPage() {
router.push({ name: 'scan-tickets', params: { activityId } })
}
const dateDisplay = computed(() => {
if (!activity.value) return ''
const a = activity.value
@ -157,6 +161,17 @@ function goToMyTickets() {
Back
</Button>
<div class="flex items-center gap-1.5">
<Button
v-if="ownedLnbitsEvent"
variant="ghost"
size="sm"
class="gap-1.5"
@click="openScannerPage"
aria-label="Scan tickets"
>
<ScanLine class="w-4 h-4" />
Scan
</Button>
<Button
v-if="ownedLnbitsEvent"
variant="ghost"

View file

@ -0,0 +1,214 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, CheckCircle2, AlertCircle, Clock, Ticket, Trash2, ScanLine } from 'lucide-vue-next'
import { format } from 'date-fns'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { ScrollArea } from '@/components/ui/scroll-area'
import QRScanner from '@/components/ui/qr-scanner.vue'
import { useTicketScanner } from '../composables/useTicketScanner'
import { useActivityDetail } from '../composables/useActivityDetail'
const route = useRoute()
const router = useRouter()
const activityId = ref(route.params.activityId as string)
const { activity } = useActivityDetail(activityId.value)
const {
isProcessing,
isPaused,
lastScan,
scanned,
onDecode,
resume,
clearScanned,
} = useTicketScanner(activityId)
const scannerOpen = ref(true)
const lastScanVariant = computed(() => {
switch (lastScan.value?.status) {
case 'ok':
return 'success'
case 'duplicate-session':
return 'warning'
case 'error':
return 'destructive'
default:
return null
}
})
function handleResult(qrText: string) {
// Don't pause the scanner — useQRScanner's `maxScansPerSecond: 5`
// already throttles, and useTicketScanner.onDecode dedups the same
// ticket id at the session-list level.
void onDecode(qrText)
}
function goBack() {
if (window.history.length > 1) router.back()
else router.push({ name: 'activity-detail', params: { id: activityId.value } })
}
function fmtTime(iso: string) {
try {
return format(new Date(iso), 'HH:mm:ss')
} catch {
return iso
}
}
</script>
<template>
<div class="container mx-auto py-6 px-4 max-w-2xl">
<!-- Top bar -->
<div class="flex items-center justify-between mb-4">
<Button variant="ghost" size="sm" class="gap-1.5" @click="goBack">
<ArrowLeft class="w-4 h-4" />
Back
</Button>
<div class="flex items-center gap-1.5 text-sm text-muted-foreground">
<Ticket class="w-4 h-4" />
{{ scanned.length }} scanned this session
</div>
</div>
<h1 class="text-2xl sm:text-3xl font-bold text-foreground mb-1">Scan tickets</h1>
<p v-if="activity" class="text-sm text-muted-foreground mb-4">
{{ activity.title }}
</p>
<!-- Scanner -->
<div v-if="scannerOpen" class="bg-card rounded-lg border border-border overflow-hidden">
<QRScanner @result="handleResult" @close="scannerOpen = false" />
</div>
<div v-else class="flex justify-center my-6">
<Button @click="scannerOpen = true" class="gap-1.5">
<Ticket class="w-4 h-4" />
Resume scanning
</Button>
</div>
<!-- Last-scan result. Sticks until the operator taps "Scan
next" — gives them time to verify the outcome and act on
it (let the attendee in, deny entry, etc.) before the
decode loop picks up the next QR. -->
<div
v-if="lastScan"
class="mt-4 p-4 rounded-lg border flex items-start gap-3"
:class="{
'bg-emerald-500/10 border-emerald-500/40': lastScanVariant === 'success',
'bg-amber-500/10 border-amber-500/40': lastScanVariant === 'warning',
'bg-destructive/10 border-destructive/40': lastScanVariant === 'destructive',
}"
>
<CheckCircle2
v-if="lastScanVariant === 'success'"
class="w-6 h-6 text-emerald-500 shrink-0 mt-0.5"
/>
<Clock
v-else-if="lastScanVariant === 'warning'"
class="w-6 h-6 text-amber-500 shrink-0 mt-0.5"
/>
<AlertCircle
v-else
class="w-6 h-6 text-destructive shrink-0 mt-0.5"
/>
<div class="flex-1 min-w-0">
<p class="text-base font-semibold text-foreground">
<template v-if="lastScan.status === 'ok'">
Registered
<span v-if="lastScan.ticket?.name" class="font-normal text-muted-foreground">
— {{ lastScan.ticket.name }}
</span>
</template>
<template v-else-if="lastScan.status === 'duplicate-session'">
Already scanned in this session
</template>
<template v-else>
{{ lastScan.message || 'Scan failed' }}
</template>
</p>
<p class="text-xs font-mono text-muted-foreground break-all mt-1">
{{ lastScan.ticketId }}
</p>
</div>
</div>
<!-- "Scan next" CTA — primary action when a result is pending,
so the operator's hand goes to the same place every time
(full-width button below the result). Disabled while the
RPC is still in-flight. -->
<Button
v-if="isPaused"
class="w-full mt-3 gap-1.5"
size="lg"
:disabled="isProcessing"
@click="resume"
>
<ScanLine class="w-4 h-4" />
<span v-if="isProcessing">Sending…</span>
<span v-else>Scan next</span>
</Button>
<p
v-else-if="isProcessing"
class="text-xs text-center text-muted-foreground mt-3"
>
Sending registration over Nostr…
</p>
<Separator class="my-6" />
<!-- Scanned-this-session list. Persists to localStorage per
activity, mirroring the LNbits admin register page's
events_scanned_<eventId> pattern. -->
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-sm font-medium text-foreground">
Scanned ({{ scanned.length }})
</h2>
<Button
v-if="scanned.length > 0"
variant="ghost"
size="sm"
class="gap-1.5 text-xs"
@click="clearScanned"
>
<Trash2 class="w-3.5 h-3.5" />
Clear list
</Button>
</div>
<ScrollArea v-if="scanned.length > 0" class="h-72">
<ul class="space-y-1.5 pr-3">
<li
v-for="record in scanned"
:key="record.ticketId"
class="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/40 text-sm"
>
<div class="min-w-0">
<div class="flex items-center gap-2">
<Badge variant="secondary" class="text-[10px] font-mono px-1.5">
{{ fmtTime(record.registeredAt) }}
</Badge>
<span v-if="record.name" class="font-medium text-foreground">
{{ record.name }}
</span>
</div>
<p class="font-mono text-[10px] text-muted-foreground break-all mt-0.5">
{{ record.ticketId }}
</p>
</div>
<CheckCircle2 class="w-4 h-4 text-emerald-500 shrink-0" />
</li>
</ul>
</ScrollArea>
<p v-else class="text-sm text-muted-foreground text-center py-6">
No tickets scanned yet.
</p>
</div>
</div>
</template>

View file

@ -5,6 +5,7 @@ import { relayHub } from './nostr/relay-hub'
import { NostrMetadataService } from './nostr/nostr-metadata-service'
import { ProfileService } from './nostr/ProfileService'
import { ReactionService } from './nostr/ReactionService'
import { NostrTransportService } from './services/NostrTransportService'
// Import auth services
import { auth } from './auth/auth-service'
@ -32,6 +33,7 @@ const imageUploadService = new ImageUploadService()
const nostrMetadataService = new NostrMetadataService()
const profileService = new ProfileService()
const reactionService = new ReactionService()
const nostrTransportService = new NostrTransportService()
/**
* Base Module Plugin
@ -75,6 +77,7 @@ export const baseModule: ModulePlugin = {
// Register shared Nostr services (used by multiple modules)
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
container.provide(SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE, nostrTransportService)
// Register PWA service
container.provide('pwaService', pwaService)
@ -122,6 +125,10 @@ export const baseModule: ModulePlugin = {
waitForDependencies: true, // ReactionService depends on RelayHub and AuthService
maxRetries: 3
})
await nostrTransportService.initialize({
waitForDependencies: true, // NostrTransportService depends on RelayHub and AuthService
maxRetries: 3
})
// InvoiceService doesn't need initialization as it's not a BaseService
console.log('✅ Base module installed successfully')
@ -141,6 +148,7 @@ export const baseModule: ModulePlugin = {
await nostrMetadataService.dispose()
await profileService.dispose()
await reactionService.dispose()
await nostrTransportService.dispose()
// InvoiceService doesn't need disposal as it's not a BaseService
await lnbitsAPI.dispose()

View file

@ -0,0 +1,245 @@
import { finalizeEvent, nip44, type EventTemplate, type Event } from 'nostr-tools'
import { BaseService } from '@/core/base/BaseService'
import { LNBITS_CONFIG } from '@/lib/config/lnbits'
import type { AuthService } from '@/modules/base/auth/auth-service'
import type { RelayHub } from '@/modules/base/nostr/relay-hub'
/**
* Client for LNbits's nostr-transport (kind-21000 RPC over relays,
* landed upstream Sun May 24 2026, commit f235966c).
*
* The webapp wraps its RPC payload in an NIP-44 v2 encrypted JSON
* envelope, signs a kind-21000 event with the current user's Nostr
* key, publishes via RelayHub, and listens for a signed response
* from the LNbits server pubkey addressed back to us. Shards
* (per Lightning.Pub's `{part,index,totalShards,shardsId}` format)
* are reassembled before parsing.
*
* This is the preferred call site for any backend op that has a
* registered handler — the activities ticket scanner is the first
* webapp consumer; wallet ops + event CRUD are obvious next adopters.
*/
const TRANSPORT_KIND = 21000
interface RpcCallOptions {
/** Wallet id to send as part of the RPC body — required for
* AUTH_WALLET endpoints. Defaults to currentUser.wallets[0].id. */
walletId?: string
/** Per-call timeout in ms before throwing. Default 15s. */
timeoutMs?: number
}
interface ShardWrapper {
part: string
index: number
totalShards: number
shardsId: string
}
interface RpcRequestEnvelope {
rpc_name: string
request_id: string
wallet_id?: string
body?: Record<string, unknown>
query?: Record<string, unknown>
}
interface RpcResponseEnvelope<T = unknown> {
status: 'OK' | 'ERROR'
request_id: string
subscription_id?: string | null
data?: T | null
error?: string | null
}
export class NostrRpcError extends Error {
constructor(public readonly message: string) {
super(message)
this.name = 'NostrRpcError'
}
}
export class NostrTransportService extends BaseService {
protected readonly metadata = {
name: 'NostrTransportService',
version: '1.0.0',
dependencies: ['AuthService', 'RelayHub'],
}
/** Server pubkey (hex). Logged by the LNbits server at startup
* and configured via VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY. */
private get serverPubkey(): string {
return LNBITS_CONFIG.NOSTR_TRANSPORT_PUBKEY
}
protected async onInitialize(): Promise<void> {
if (!this.serverPubkey) {
this.debug(
'No VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY configured — RPC calls will fail',
)
} else {
this.debug(`Initialized with server pubkey ${this.serverPubkey.slice(0, 16)}…`)
}
}
/**
* Invoke an LNbits nostr-transport RPC and await the response.
*
* Throws `NostrRpcError` on backend ERROR responses (e.g. "Ticket
* not paid for"), a generic Error on signing / encryption / timeout
* failures.
*/
async call<T = unknown>(
rpcName: string,
body: Record<string, unknown>,
options: RpcCallOptions = {},
): Promise<T> {
const auth = this.authService as AuthService | undefined
const user = auth?.currentUser.value
if (!user || !user.prvkey || !user.pubkey) {
throw new Error('Sign-in with a Nostr key required to call RPC')
}
if (!this.serverPubkey) {
throw new Error(
'Missing VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY — cannot reach LNbits',
)
}
const walletId = options.walletId ?? user.wallets?.[0]?.id
const timeoutMs = options.timeoutMs ?? 15_000
const requestId = crypto.randomUUID()
const privkeyBytes = hexToBytes(user.prvkey)
const conversationKey = nip44.v2.utils.getConversationKey(
privkeyBytes,
this.serverPubkey,
)
const envelope: RpcRequestEnvelope = {
rpc_name: rpcName,
request_id: requestId,
...(walletId ? { wallet_id: walletId } : {}),
body,
}
const ciphertext = nip44.v2.encrypt(JSON.stringify(envelope), conversationKey)
const template: EventTemplate = {
kind: TRANSPORT_KIND,
created_at: Math.floor(Date.now() / 1000),
tags: [['p', this.serverPubkey]],
content: ciphertext,
}
const signedEvent = finalizeEvent(template, privkeyBytes)
// Subscribe BEFORE publishing so we can't miss a fast reply.
const replyPromise = this.waitForReply<T>(
requestId,
user.pubkey,
conversationKey,
timeoutMs,
)
const hub = this.relayHub as RelayHub
const publishResult = await hub.publishEvent(signedEvent)
if (publishResult.success === 0) {
throw new Error('Failed to publish RPC event to any relay')
}
return replyPromise
}
private async waitForReply<T>(
requestId: string,
clientPubkey: string,
conversationKey: Uint8Array,
timeoutMs: number,
): Promise<T> {
const hub = this.relayHub as RelayHub
const shards = new Map<string, Map<number, string>>()
const shardsExpected = new Map<string, number>()
let unsubscribe: (() => void) | null = null
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
unsubscribe?.()
reject(new Error(`RPC ${requestId} timed out after ${timeoutMs}ms`))
}, timeoutMs)
unsubscribe = hub.subscribe({
id: `nostr-rpc-${requestId}`,
filters: [
{
kinds: [TRANSPORT_KIND],
'#p': [clientPubkey],
authors: [this.serverPubkey],
// Don't bound by `since` — the server may publish before
// the relay has accepted our subscription create. The
// request_id match below is the actual filter.
},
],
onEvent: (event: Event) => {
let plaintext: string
try {
plaintext = nip44.v2.decrypt(event.content, conversationKey)
} catch (e) {
this.debug(`Skipping undecryptable event ${event.id.slice(0, 16)}: ${e}`)
return
}
// Reassemble shards (Lightning.Pub wrapper format) before
// attempting to parse as an RPC response. Plaintexts that
// don't carry the wrapper are full single-event responses.
let fullText: string | null = plaintext
try {
const maybeShard = JSON.parse(plaintext) as Partial<ShardWrapper>
if (
typeof maybeShard.shardsId === 'string' &&
typeof maybeShard.index === 'number' &&
typeof maybeShard.totalShards === 'number' &&
typeof maybeShard.part === 'string'
) {
const buf = shards.get(maybeShard.shardsId) ?? new Map<number, string>()
buf.set(maybeShard.index, maybeShard.part)
shards.set(maybeShard.shardsId, buf)
shardsExpected.set(maybeShard.shardsId, maybeShard.totalShards)
if (buf.size < maybeShard.totalShards) {
return // wait for more shards
}
const sorted = Array.from({ length: maybeShard.totalShards }, (_, i) =>
buf.get(i) ?? '',
)
fullText = sorted.join('')
}
} catch {
// not JSON → fall through with plaintext as-is
}
let response: RpcResponseEnvelope<T>
try {
response = JSON.parse(fullText) as RpcResponseEnvelope<T>
} catch (e) {
this.debug(`Skipping unparseable RPC payload: ${e}`)
return
}
if (response.request_id !== requestId) return // not for us
clearTimeout(timer)
unsubscribe?.()
if (response.status === 'OK') {
resolve(response.data as T)
} else {
reject(new NostrRpcError(response.error ?? 'Unknown RPC error'))
}
},
})
})
}
}
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.slice(i, i + 2), 16)
}
return bytes
}