feat(docker): add dev.sh with auto-funding and ATM app setup
- Add dev.sh script for managing regtest development environment - Implement cmd_fund to fund ATM app owner via Lightning.Pub API - Add --fund flag to cmd_up for automatic funding on startup - Update setup_atm_app to write VITE_APP_ID to machine .env - Fix Electron IPC to pass appId and extensionApiUrl to renderer - Restructure repo from nested lamassu-next/ to root The dev.sh script now supports: - ./dev.sh up --fund # Start regtest and auto-fund ATM - ./dev.sh fund # Fund existing ATM app - ./dev.sh status # Show environment status - ./dev.sh reset # Clean restart Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
30a2eb2199
commit
c98f126ba7
180 changed files with 2695 additions and 9587 deletions
365
packages/nostr-client/src/client.ts
Normal file
365
packages/nostr-client/src/client.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
/**
|
||||
* 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,
|
||||
type VerifiedEvent,
|
||||
Relay,
|
||||
SimplePool,
|
||||
verifyEvent,
|
||||
} 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: 5,
|
||||
...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)
|
||||
}
|
||||
}
|
||||
|
||||
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 = createAuthEvent(this.config.identity, connection.config.url, challenge)
|
||||
// Verify the event to get a VerifiedEvent type
|
||||
if (verifyEvent(authEvent)) {
|
||||
return authEvent as VerifiedEvent
|
||||
}
|
||||
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)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a reconnection attempt
|
||||
*/
|
||||
private scheduleReconnect(url: string): void {
|
||||
const connection = this.connections.get(url)
|
||||
if (!connection) return
|
||||
|
||||
if (connection.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
||||
return
|
||||
}
|
||||
|
||||
connection.reconnectAttempts++
|
||||
const delay = Math.min(1000 * Math.pow(2, connection.reconnectAttempts), 30000)
|
||||
|
||||
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.identity.publicKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the machine's npub
|
||||
*/
|
||||
get npub(): string {
|
||||
return this.config.identity.npub
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue