feat(activities): organizer ticket scanner over Nostr transport #73
5 changed files with 269 additions and 0 deletions
feat(base): NostrTransportService — nip44 v2 kind-21000 RPC client for LNbits
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) <noreply@anthropic.com>
commit
02c1be0ba7
|
|
@ -11,6 +11,13 @@ VITE_API_KEY=your-api-key-here
|
||||||
VITE_LNBITS_DEBUG=false
|
VITE_LNBITS_DEBUG=false
|
||||||
VITE_WEBSOCKET_ENABLED=true
|
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)
|
# Lightning Address Domain (optional)
|
||||||
# Override the domain used for Lightning Addresses
|
# Override the domain used for Lightning Addresses
|
||||||
# If not set, domain will be extracted from VITE_LNBITS_BASE_URL
|
# If not set, domain will be extracted from VITE_LNBITS_BASE_URL
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,9 @@ export const SERVICE_TOKENS = {
|
||||||
// Nostr metadata services
|
// Nostr metadata services
|
||||||
NOSTR_METADATA_SERVICE: Symbol('nostrMetadataService'),
|
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 services (Nostr-native events + ticketing module)
|
||||||
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
|
ACTIVITIES_NOSTR_SERVICE: Symbol('activitiesNostrService'),
|
||||||
ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'),
|
ACTIVITIES_TICKET_API: Symbol('activitiesTicketApi'),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@ export const LNBITS_CONFIG = {
|
||||||
// This should point to your LNBits instance
|
// This should point to your LNBits instance
|
||||||
API_BASE_URL: `${import.meta.env.VITE_LNBITS_BASE_URL || ''}/api/v1`,
|
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
|
// Whether to enable debug logging
|
||||||
DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true',
|
DEBUG: import.meta.env.VITE_LNBITS_DEBUG === 'true',
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { relayHub } from './nostr/relay-hub'
|
||||||
import { NostrMetadataService } from './nostr/nostr-metadata-service'
|
import { NostrMetadataService } from './nostr/nostr-metadata-service'
|
||||||
import { ProfileService } from './nostr/ProfileService'
|
import { ProfileService } from './nostr/ProfileService'
|
||||||
import { ReactionService } from './nostr/ReactionService'
|
import { ReactionService } from './nostr/ReactionService'
|
||||||
|
import { NostrTransportService } from './services/NostrTransportService'
|
||||||
|
|
||||||
// Import auth services
|
// Import auth services
|
||||||
import { auth } from './auth/auth-service'
|
import { auth } from './auth/auth-service'
|
||||||
|
|
@ -32,6 +33,7 @@ const imageUploadService = new ImageUploadService()
|
||||||
const nostrMetadataService = new NostrMetadataService()
|
const nostrMetadataService = new NostrMetadataService()
|
||||||
const profileService = new ProfileService()
|
const profileService = new ProfileService()
|
||||||
const reactionService = new ReactionService()
|
const reactionService = new ReactionService()
|
||||||
|
const nostrTransportService = new NostrTransportService()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Base Module Plugin
|
* Base Module Plugin
|
||||||
|
|
@ -75,6 +77,7 @@ export const baseModule: ModulePlugin = {
|
||||||
// Register shared Nostr services (used by multiple modules)
|
// Register shared Nostr services (used by multiple modules)
|
||||||
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
container.provide(SERVICE_TOKENS.PROFILE_SERVICE, profileService)
|
||||||
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
|
container.provide(SERVICE_TOKENS.REACTION_SERVICE, reactionService)
|
||||||
|
container.provide(SERVICE_TOKENS.NOSTR_TRANSPORT_SERVICE, nostrTransportService)
|
||||||
|
|
||||||
// Register PWA service
|
// Register PWA service
|
||||||
container.provide('pwaService', pwaService)
|
container.provide('pwaService', pwaService)
|
||||||
|
|
@ -122,6 +125,10 @@ export const baseModule: ModulePlugin = {
|
||||||
waitForDependencies: true, // ReactionService depends on RelayHub and AuthService
|
waitForDependencies: true, // ReactionService depends on RelayHub and AuthService
|
||||||
maxRetries: 3
|
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
|
// InvoiceService doesn't need initialization as it's not a BaseService
|
||||||
|
|
||||||
console.log('✅ Base module installed successfully')
|
console.log('✅ Base module installed successfully')
|
||||||
|
|
@ -141,6 +148,7 @@ export const baseModule: ModulePlugin = {
|
||||||
await nostrMetadataService.dispose()
|
await nostrMetadataService.dispose()
|
||||||
await profileService.dispose()
|
await profileService.dispose()
|
||||||
await reactionService.dispose()
|
await reactionService.dispose()
|
||||||
|
await nostrTransportService.dispose()
|
||||||
// InvoiceService doesn't need disposal as it's not a BaseService
|
// InvoiceService doesn't need disposal as it's not a BaseService
|
||||||
await lnbitsAPI.dispose()
|
await lnbitsAPI.dispose()
|
||||||
|
|
||||||
|
|
|
||||||
245
src/modules/base/services/NostrTransportService.ts
Normal file
245
src/modules/base/services/NostrTransportService.ts
Normal 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
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue