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
}