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>
403 lines
11 KiB
TypeScript
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)
|
|
}
|
|
}
|