bitspire/packages/nostr-client/src/client.ts
Padreug d6b22e1156 refactor(nostr): route signing + encryption through a Signer abstraction
Introduce a Signer interface (signEvent / nip44Encrypt / nip44Decrypt +
sync pubkey) with an in-process LocalSigner backed by an nsec, and route
every signing/encryption call site through it. Behaviour is unchanged —
LocalSigner wraps the same MachineIdentity the code used directly before.

This is Phase A of the bunker migration (aiolabs/bitspire#52): it puts the
seam in place so Phase B can drop in a NIP-46 BunkerSigner at the bootstrap
without touching any call site. The whole chain becomes async (the bunker
path is a relay round-trip; LocalSigner resolves immediately).

Sites moved onto the signer:
- packages/nostr-client: createSignedEvent / createAuthEvent (now async),
  NostrClient config (signer not identity), AUTH challenge handler.
- packages/lnbits: LnbitsClient.initialize(nostr, signer); kind-21000 RPC
  encrypt + sign + reply-decrypt; handleReply is now async (event-id dedup
  still runs synchronously before the awaited decrypt, so replay safety and
  per-subscription hash dedup are preserved).
- apps/machine: lightning.ts builds a LocalSigner and exposes it on
  LightningServices; operator-config / operator-fees / availability beacon /
  maintenance beacon / fund-atm all sign + encrypt via the signer.

NIP-42 auth (kind 22242) is included — under the bunker it must be in the
spire policy (aiolabs/spirekeeper#26, already merged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-18 19:56:35 +02:00

403 lines
11 KiB
TypeScript

/**
* Nostr client for Lamassu ATM
*
* Manages connections to Nostr relays with support for:
* - NIP-42 authentication
* - Event publishing and subscription
* - Automatic reconnection
*/
import { type Event, type Filter, Relay, SimplePool, verifyEvent, nip19 } from 'nostr-tools'
import { createAuthEvent } from './events.js'
import type {
NostrClientConfig,
RelayConfig,
SubscriptionFilter,
SubscriptionOptions,
ConnectionState,
EventHandler,
} from './types.js'
interface RelayConnection {
config: RelayConfig
relay: Relay | null
state: ConnectionState
reconnectAttempts: number
}
interface Subscription {
id: string
filters: Filter[]
options: SubscriptionOptions
close: () => void
}
/**
* Nostr client for ATM communication
*/
export class NostrClient {
private config: Required<NostrClientConfig>
private connections: Map<string, RelayConnection> = new Map()
private subscriptions: Map<string, Subscription> = new Map()
private pool: SimplePool
private eventHandlers: Map<string, Set<EventHandler>> = new Map()
private subscriptionCounter = 0
constructor(config: NostrClientConfig) {
this.config = {
connectionTimeout: 10000,
autoReconnect: true,
maxReconnectAttempts: Infinity,
...config,
}
this.pool = new SimplePool()
// Initialize connections
for (const relayConfig of this.config.relays) {
this.connections.set(relayConfig.url, {
config: relayConfig,
relay: null,
state: 'disconnected',
reconnectAttempts: 0,
})
}
}
/**
* Connect to all configured relays
*/
async connect(): Promise<void> {
const connectPromises = Array.from(this.connections.keys()).map((url) =>
this.connectToRelay(url)
)
await Promise.allSettled(connectPromises)
}
/**
* Connect to a specific relay
*/
private async connectToRelay(url: string): Promise<void> {
const connection = this.connections.get(url)
if (!connection) return
connection.state = 'connecting'
try {
const relay = await Relay.connect(url)
connection.relay = relay
connection.state = 'connected'
connection.reconnectAttempts = 0
// Handle NIP-42 auth if required
if (connection.config.requiresAuth) {
await this.handleAuth(connection)
} else {
// Mark as authenticated if no auth required
connection.state = 'authenticated'
}
// Set up event handlers
relay.onclose = () => {
connection.state = 'disconnected'
this.emitEvent('disconnect', { relay: url })
if (this.config.autoReconnect) {
this.scheduleReconnect(url)
}
}
// Re-establish all active subscriptions on the new relay connection
this.resubscribeOnRelay(connection)
this.emitEvent('connect', { relay: url })
} catch (error) {
connection.state = 'error'
this.emitEvent('error', {
relay: url,
error: error instanceof Error ? error : new Error(String(error)),
})
if (this.config.autoReconnect) {
this.scheduleReconnect(url)
}
}
}
/**
* Handle NIP-42 authentication
*/
private async handleAuth(connection: RelayConnection): Promise<void> {
if (!connection.relay) return
connection.state = 'authenticating'
const relay = connection.relay
return new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error('Auth timeout'))
}, this.config.connectionTimeout)
// The relay will send an AUTH challenge when auth is required
// We respond by publishing an auth event
relay
.auth(async (evt) => {
// evt is the challenge event template from the relay
// We need to extract the challenge and create our auth response
const challenge =
evt.tags?.find((t): t is [string, string] => t[0] === 'challenge')?.[1] ?? ''
const authEvent = await createAuthEvent(
this.config.signer,
connection.config.url,
challenge
)
// The signer returns a fully-signed event; re-verify defensively
// (a remote bunker could in principle return a malformed reply).
if (verifyEvent(authEvent)) {
return authEvent
}
throw new Error('Failed to create valid auth event')
})
.then(() => {
clearTimeout(timeout)
connection.state = 'authenticated'
this.emitEvent('auth', { relay: connection.config.url, success: true })
resolve()
})
.catch((error) => {
clearTimeout(timeout)
connection.state = 'error'
this.emitEvent('auth', { relay: connection.config.url, success: false })
reject(error)
})
})
}
/**
* Re-establish all active subscriptions on a reconnected relay.
* When a relay drops and reconnects, the old Relay instance is gone —
* subscriptions need to be recreated on the new instance.
*/
private resubscribeOnRelay(connection: RelayConnection): void {
if (!connection.relay) return
for (const [id, sub] of this.subscriptions) {
try {
const relaySub = connection.relay.subscribe(sub.filters, {
onevent: (event: Event) => {
sub.options.onEvent(event)
this.emitEvent('event', { relay: connection.config.url, event })
},
oneose: () => {
sub.options.onEose?.()
},
})
// Append to the existing close chain so unsubscribe() cleans up both
const prevClose = sub.close
sub.close = () => {
prevClose()
relaySub.close()
}
console.log(`[Nostr] Restored subscription ${id} on ${connection.config.url}`)
} catch (e) {
console.warn(`[Nostr] Failed to restore subscription ${id} on ${connection.config.url}:`, e)
}
}
}
/**
* Schedule a reconnection attempt
*/
private scheduleReconnect(url: string): void {
const connection = this.connections.get(url)
if (!connection) return
if (connection.reconnectAttempts >= this.config.maxReconnectAttempts) {
console.warn(`[Nostr] Relay ${url}: max reconnect attempts reached`)
return
}
connection.reconnectAttempts++
// Exponential backoff: 2s, 4s, 8s, 16s, 32s, then cap at 60s
const delay = Math.min(1000 * Math.pow(2, connection.reconnectAttempts), 60000)
console.log(
`[Nostr] Relay ${url}: reconnecting in ${delay / 1000}s (attempt ${connection.reconnectAttempts})`
)
setTimeout(() => {
this.connectToRelay(url)
}, delay)
}
/**
* Publish an event to all writable relays
*/
async publish(event: Event): Promise<void> {
const writableUrls = Array.from(this.connections.values())
.filter((c) => !c.config.readOnly && c.state === 'authenticated')
.map((c) => c.config.url)
if (writableUrls.length === 0) {
throw new Error('No writable relays available')
}
await Promise.all(this.pool.publish(writableUrls, event))
}
/**
* Subscribe to events matching filters
*
* Uses direct Relay connections instead of SimplePool for real-time event delivery.
*/
subscribe(filters: SubscriptionFilter[], options: SubscriptionOptions): string {
const id = `sub_${++this.subscriptionCounter}`
// Get connected relay instances
const connectedRelays = Array.from(this.connections.values())
.filter((c) => c.state === 'authenticated' || c.state === 'connected')
.filter((c) => c.relay !== null)
if (connectedRelays.length === 0) {
throw new Error('No connected relays')
}
// Subscribe on each connected relay directly (not through pool)
const subs: Array<{ close: () => void }> = []
for (const conn of connectedRelays) {
if (!conn.relay) continue
const sub = conn.relay.subscribe(filters as Filter[], {
onevent: (event: Event) => {
options.onEvent(event)
this.emitEvent('event', { relay: conn.config.url, event })
},
oneose: () => {
options.onEose?.()
if (options.closeOnEose) {
this.unsubscribe(id)
}
},
})
subs.push(sub)
}
this.subscriptions.set(id, {
id,
filters: filters as unknown as Filter[],
options,
close: () => subs.forEach((s) => s.close()),
})
return id
}
/**
* Unsubscribe from a subscription
*/
unsubscribe(subscriptionId: string): void {
const sub = this.subscriptions.get(subscriptionId)
if (sub) {
sub.close()
this.subscriptions.delete(subscriptionId)
}
}
/**
* Query events (one-time fetch)
*/
async queryEvents(filters: SubscriptionFilter[]): Promise<Event[]> {
const connectedUrls = Array.from(this.connections.values())
.filter((c) => c.state === 'authenticated' || c.state === 'connected')
.map((c) => c.config.url)
if (connectedUrls.length === 0) {
throw new Error('No connected relays')
}
// @ts-expect-error nostr-tools types expect single Filter but querySync accepts array
return this.pool.querySync(connectedUrls, filters)
}
/**
* Disconnect from all relays
*/
disconnect(): void {
// Close all subscriptions
for (const sub of this.subscriptions.values()) {
sub.close()
}
this.subscriptions.clear()
// Disconnect all relays
for (const connection of this.connections.values()) {
connection.relay?.close()
connection.state = 'disconnected'
}
this.pool.close(Array.from(this.connections.keys()))
}
/**
* Get connection state for a relay
*/
getConnectionState(url: string): ConnectionState | undefined {
return this.connections.get(url)?.state
}
/**
* Get all connection states
*/
getConnectionStates(): Map<string, ConnectionState> {
return new Map(Array.from(this.connections.entries()).map(([url, conn]) => [url, conn.state]))
}
/**
* Add event listener for client events
*/
on(event: string, handler: EventHandler): void {
if (!this.eventHandlers.has(event)) {
this.eventHandlers.set(event, new Set())
}
this.eventHandlers.get(event)!.add(handler)
}
/**
* Remove event listener
*/
off(event: string, handler: EventHandler): void {
this.eventHandlers.get(event)?.delete(handler)
}
/**
* Emit an event to handlers
*/
private emitEvent(event: string, data: unknown): void {
const handlers = this.eventHandlers.get(event)
if (handlers) {
for (const handler of handlers) {
try {
handler(data as Event)
} catch {
// Ignore handler errors
}
}
}
}
/**
* Get the machine's public key
*/
get publicKey(): string {
return this.config.signer.pubkey
}
/**
* Get the machine's npub
*/
get npub(): string {
return nip19.npubEncode(this.config.signer.pubkey)
}
}