From 02c1be0ba7cc7c94d307e83f284d498a144a0563 Mon Sep 17 00:00:00 2001 From: Padreug Date: Sun, 24 May 2026 16:46:31 +0200 Subject: [PATCH] =?UTF-8?q?feat(base):=20NostrTransportService=20=E2=80=94?= =?UTF-8?q?=20nip44=20v2=20kind-21000=20RPC=20client=20for=20LNbits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generic client for LNbits's nostr-transport (landed upstream Sun May 24, commit f235966c). Encrypts a request envelope to the server's transport pubkey with NIP-44 v2, signs a kind-21000 event with the current user's Nostr key, publishes via RelayHub, and listens for a signed response addressed back to us. Shards (Lightning.Pub's `{part, index, totalShards, shardsId}` wrapper) are reassembled before parsing. Activities ticket scanner is the first consumer; wallet ops + event CRUD are obvious next adopters (file as follow-up). Server pubkey discovery is currently env-var (VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY) — see also the follow-up to add a `.well-known` discovery endpoint on LNbits. Co-Authored-By: Claude Opus 4.7 (1M context) --- .env.example | 7 + src/core/di-container.ts | 3 + src/lib/config/lnbits.ts | 6 + src/modules/base/index.ts | 8 + .../base/services/NostrTransportService.ts | 245 ++++++++++++++++++ 5 files changed, 269 insertions(+) create mode 100644 src/modules/base/services/NostrTransportService.ts diff --git a/.env.example b/.env.example index 99327b7..ecae912 100644 --- a/.env.example +++ b/.env.example @@ -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 ... 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 diff --git a/src/core/di-container.ts b/src/core/di-container.ts index 15aa084..f6c87cd 100644 --- a/src/core/di-container.ts +++ b/src/core/di-container.ts @@ -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'), diff --git a/src/lib/config/lnbits.ts b/src/lib/config/lnbits.ts index dec6c8e..cc33aff 100644 --- a/src/lib/config/lnbits.ts +++ b/src/lib/config/lnbits.ts @@ -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 ...`). + NOSTR_TRANSPORT_PUBKEY: import.meta.env.VITE_LNBITS_NOSTR_TRANSPORT_PUBKEY || '', + // Whether to enable debug logging DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true', diff --git a/src/modules/base/index.ts b/src/modules/base/index.ts index ad9cf20..16b9fbc 100644 --- a/src/modules/base/index.ts +++ b/src/modules/base/index.ts @@ -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() diff --git a/src/modules/base/services/NostrTransportService.ts b/src/modules/base/services/NostrTransportService.ts new file mode 100644 index 0000000..8ce4974 --- /dev/null +++ b/src/modules/base/services/NostrTransportService.ts @@ -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 + query?: Record +} + +interface RpcResponseEnvelope { + 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 { + 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( + rpcName: string, + body: Record, + options: RpcCallOptions = {}, + ): Promise { + 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( + 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( + requestId: string, + clientPubkey: string, + conversationKey: Uint8Array, + timeoutMs: number, + ): Promise { + const hub = this.relayHub as RelayHub + const shards = new Map>() + const shardsExpected = new Map() + let unsubscribe: (() => void) | null = null + + return new Promise((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 + 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() + 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 + try { + response = JSON.parse(fullText) as RpcResponseEnvelope + } 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 +}