diff --git a/.gitignore b/.gitignore index 68dff3d..ea40c05 100644 --- a/.gitignore +++ b/.gitignore @@ -48,3 +48,6 @@ coverage/ # Pre-commit (auto-generated by devenv) .pre-commit-config.yaml + +# External dependencies (cloned for docker) +Lightning.Pub/ diff --git a/lamassu-next/packages/nostr-client/package.json b/lamassu-next/packages/nostr-client/package.json index ea59877..1158d7b 100644 --- a/lamassu-next/packages/nostr-client/package.json +++ b/lamassu-next/packages/nostr-client/package.json @@ -21,9 +21,11 @@ "validate-schemas": "tsx scripts/validate-schemas.ts" }, "dependencies": { - "nostr-tools": "^2.10.0" + "nostr-tools": "^2.10.0", + "ws": "^8.18.0" }, "devDependencies": { + "@types/ws": "^8.5.13", "@types/node": "^22.0.0", "typescript": "^5.7.0", "vitest": "^2.1.0", diff --git a/lamassu-next/packages/nostr-client/scripts/validate-schemas.ts b/lamassu-next/packages/nostr-client/scripts/validate-schemas.ts new file mode 100644 index 0000000..29b5c00 --- /dev/null +++ b/lamassu-next/packages/nostr-client/scripts/validate-schemas.ts @@ -0,0 +1,14 @@ +/** + * Schema validation script for Nostr events + * + * This script validates that event schemas conform to Nostr NIPs. + * Used by pre-commit hooks to catch schema errors early. + */ + +// TODO: Implement schema validation +// - Validate event kind numbers match NIP specifications +// - Validate tag formats +// - Validate content structure for typed events + +console.log('Schema validation not yet implemented') +process.exit(0) diff --git a/lamassu-next/packages/nostr-client/src/__tests__/encryption.test.ts b/lamassu-next/packages/nostr-client/src/__tests__/encryption.test.ts new file mode 100644 index 0000000..7595332 --- /dev/null +++ b/lamassu-next/packages/nostr-client/src/__tests__/encryption.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from 'vitest' +import { generateIdentity } from '../identity.js' +import { encryptContent, decryptContent, decryptJSON } from '../encryption.js' + +describe('encryption', () => { + describe('encryptContent / decryptContent', () => { + it('should encrypt and decrypt string content', () => { + const sender = generateIdentity() + const recipient = generateIdentity() + const message = 'Hello, Nostr!' + + const encrypted = encryptContent(sender, recipient.publicKey, message) + + expect(encrypted).not.toBe(message) + expect(typeof encrypted).toBe('string') + + const decrypted = decryptContent(recipient, sender.publicKey, encrypted) + + expect(decrypted).toBe(message) + }) + + it('should encrypt and decrypt object content', () => { + const sender = generateIdentity() + const recipient = generateIdentity() + const data = { amount: 1000, currency: 'USD', timestamp: Date.now() } + + const encrypted = encryptContent(sender, recipient.publicKey, data) + const decrypted = decryptContent(recipient, sender.publicKey, encrypted) + + expect(JSON.parse(decrypted)).toEqual(data) + }) + }) + + describe('decryptJSON', () => { + it('should decrypt and parse JSON directly', () => { + const sender = generateIdentity() + const recipient = generateIdentity() + const data = { test: true, nested: { value: 42 } } + + const encrypted = encryptContent(sender, recipient.publicKey, data) + const decrypted = decryptJSON(recipient, sender.publicKey, encrypted) + + expect(decrypted).toEqual(data) + }) + }) +}) diff --git a/lamassu-next/packages/nostr-client/src/__tests__/events.test.ts b/lamassu-next/packages/nostr-client/src/__tests__/events.test.ts new file mode 100644 index 0000000..7f1d2ea --- /dev/null +++ b/lamassu-next/packages/nostr-client/src/__tests__/events.test.ts @@ -0,0 +1,82 @@ +import { describe, it, expect } from 'vitest' +import { generateIdentity } from '../identity.js' +import { + createSignedEvent, + createMachineStatusEvent, + createAuthEvent, + validateEvent, + generateTxId, +} from '../events.js' +import { LamassuEventKind, type MachineStatus } from '../types.js' + +describe('events', () => { + describe('createSignedEvent', () => { + it('should create a properly signed event', () => { + const identity = generateIdentity() + const event = createSignedEvent(identity, { + kind: 1, + content: 'test', + tags: [], + created_at: Math.floor(Date.now() / 1000), + }) + + expect(event.pubkey).toBe(identity.publicKey) + expect(event.kind).toBe(1) + expect(event.content).toBe('test') + expect(event.id).toMatch(/^[0-9a-f]{64}$/) + expect(event.sig).toMatch(/^[0-9a-f]{128}$/) + }) + }) + + describe('createMachineStatusEvent', () => { + it('should create encrypted status event', () => { + const machine = generateIdentity() + const operator = generateIdentity() + + const status: MachineStatus = { + online: true, + lastTransaction: Date.now(), + cashLevels: { + validator: 1000, + dispenser: [{ denomination: 20, count: 100, capacity: 500 }], + }, + errors: [], + version: '1.0.0', + } + + const event = createMachineStatusEvent(machine, operator.publicKey, status) + + expect(event.kind).toBe(LamassuEventKind.MachineStatus) + expect(event.pubkey).toBe(machine.publicKey) + expect(event.tags).toContainEqual(['d', 'status']) + expect(event.tags).toContainEqual(['p', operator.publicKey]) + // Content should be encrypted (not readable JSON) + expect(() => JSON.parse(event.content)).toThrow() + }) + }) + + describe('createAuthEvent', () => { + it('should create NIP-42 auth event', () => { + const identity = generateIdentity() + const relayUrl = 'wss://relay.test.com' + const challenge = 'random-challenge-string' + + const event = createAuthEvent(identity, relayUrl, challenge) + + expect(event.kind).toBe(LamassuEventKind.Auth) + expect(event.content).toBe('') + expect(event.tags).toContainEqual(['relay', relayUrl]) + expect(event.tags).toContainEqual(['challenge', challenge]) + }) + }) + + describe('generateTxId', () => { + it('should generate unique IDs', () => { + const ids = new Set() + for (let i = 0; i < 100; i++) { + ids.add(generateTxId()) + } + expect(ids.size).toBe(100) + }) + }) +}) diff --git a/lamassu-next/packages/nostr-client/src/__tests__/identity.test.ts b/lamassu-next/packages/nostr-client/src/__tests__/identity.test.ts new file mode 100644 index 0000000..e799b8d --- /dev/null +++ b/lamassu-next/packages/nostr-client/src/__tests__/identity.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from 'vitest' +import { + generateIdentity, + loadIdentityFromNsec, + exportToNsec, + parsePublicKey, +} from '../identity.js' + +describe('identity', () => { + describe('generateIdentity', () => { + it('should generate a valid identity', () => { + const identity = generateIdentity() + + expect(identity.privateKey).toBeInstanceOf(Uint8Array) + expect(identity.privateKey.length).toBe(32) + expect(identity.publicKey).toMatch(/^[0-9a-f]{64}$/) + expect(identity.npub).toMatch(/^npub1[a-z0-9]{58}$/) + }) + + it('should generate unique identities', () => { + const id1 = generateIdentity() + const id2 = generateIdentity() + + expect(id1.publicKey).not.toBe(id2.publicKey) + }) + }) + + describe('exportToNsec / loadIdentityFromNsec', () => { + it('should round-trip identity through nsec', () => { + const original = generateIdentity() + const nsec = exportToNsec(original) + + expect(nsec).toMatch(/^nsec1[a-z0-9]{58}$/) + + const restored = loadIdentityFromNsec(nsec) + + expect(restored.publicKey).toBe(original.publicKey) + expect(restored.npub).toBe(original.npub) + }) + }) + + describe('parsePublicKey', () => { + it('should parse hex public key', () => { + const identity = generateIdentity() + const parsed = parsePublicKey(identity.publicKey) + + expect(parsed).toBe(identity.publicKey) + }) + + it('should parse npub', () => { + const identity = generateIdentity() + const parsed = parsePublicKey(identity.npub) + + expect(parsed).toBe(identity.publicKey) + }) + + it('should throw on invalid format', () => { + expect(() => parsePublicKey('invalid')).toThrow() + }) + }) +}) diff --git a/lamassu-next/packages/nostr-client/src/client.ts b/lamassu-next/packages/nostr-client/src/client.ts new file mode 100644 index 0000000..bce064a --- /dev/null +++ b/lamassu-next/packages/nostr-client/src/client.ts @@ -0,0 +1,347 @@ +/** + * 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, useWebSocketImplementation } from 'nostr-tools' +import { createAuthEvent } from './events.js' +import type { + NostrClientConfig, + RelayConfig, + SubscriptionFilter, + SubscriptionOptions, + ConnectionState, + EventHandler, +} from './types.js' + +// Use ws for Node.js environments +if (typeof WebSocket === 'undefined') { + // Dynamic import for Node.js + import('ws').then((ws) => { + useWebSocketImplementation(ws.default as never) + }) +} + +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 + private connections: Map = new Map() + private subscriptions: Map = new Map() + private pool: SimplePool + private eventHandlers: Map> = 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 { + 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 { + 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) + } + + // 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 { + if (!connection.relay) return + + connection.state = 'authenticating' + + // Listen for AUTH challenge + // Note: nostr-tools handles this internally, but we need to provide the signed event + const relay = connection.relay + + // Subscribe to auth challenges + return new Promise((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 (challenge: string) => { + const authEvent = createAuthEvent(this.config.identity, connection.config.url, challenge) + return authEvent + }) + .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 { + 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 + */ + subscribe(filters: SubscriptionFilter[], options: SubscriptionOptions): string { + const id = `sub_${++this.subscriptionCounter}` + + 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') + } + + const sub = this.pool.subscribeMany(connectedUrls, filters as Filter[], { + onevent: (event) => { + options.onEvent(event) + this.emitEvent('event', { relay: 'pool', event }) + }, + oneose: () => { + options.onEose?.() + if (options.closeOnEose) { + this.unsubscribe(id) + } + }, + }) + + this.subscriptions.set(id, { + id, + filters: filters as Filter[], + options, + close: () => sub.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 { + 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') + } + + return this.pool.querySync(connectedUrls, filters as Filter[]) + } + + /** + * 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 { + 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 + } +} diff --git a/lamassu-next/packages/nostr-client/src/encryption.ts b/lamassu-next/packages/nostr-client/src/encryption.ts new file mode 100644 index 0000000..7371b8e --- /dev/null +++ b/lamassu-next/packages/nostr-client/src/encryption.ts @@ -0,0 +1,64 @@ +/** + * NIP-44 Encryption utilities + * + * Used for encrypting sensitive data in events (machine status, + * transaction records, operator commands). + */ + +import { nip44 } from 'nostr-tools' +import type { MachineIdentity } from './types.js' + +/** + * Encrypt content for a recipient using NIP-44 + * + * @param identity - Sender's identity (machine) + * @param recipientPubkey - Recipient's public key (hex) + * @param content - Content to encrypt (will be JSON stringified if object) + * @returns Encrypted string + */ +export function encryptContent( + identity: MachineIdentity, + recipientPubkey: string, + content: unknown +): string { + const plaintext = typeof content === 'string' ? content : JSON.stringify(content) + + const conversationKey = nip44.v2.utils.getConversationKey(identity.privateKey, recipientPubkey) + + return nip44.v2.encrypt(plaintext, conversationKey) +} + +/** + * Decrypt content from a sender using NIP-44 + * + * @param identity - Recipient's identity (machine) + * @param senderPubkey - Sender's public key (hex) + * @param ciphertext - Encrypted content + * @returns Decrypted string + */ +export function decryptContent( + identity: MachineIdentity, + senderPubkey: string, + ciphertext: string +): string { + const conversationKey = nip44.v2.utils.getConversationKey(identity.privateKey, senderPubkey) + + return nip44.v2.decrypt(ciphertext, conversationKey) +} + +/** + * Decrypt and parse JSON content + * + * @param identity - Recipient's identity + * @param senderPubkey - Sender's public key + * @param ciphertext - Encrypted content + * @returns Parsed JSON object + */ +export function decryptJSON( + identity: MachineIdentity, + senderPubkey: string, + ciphertext: string +): T { + const plaintext = decryptContent(identity, senderPubkey, ciphertext) + return JSON.parse(plaintext) as T +} diff --git a/lamassu-next/packages/nostr-client/src/events.ts b/lamassu-next/packages/nostr-client/src/events.ts new file mode 100644 index 0000000..7b04d2f --- /dev/null +++ b/lamassu-next/packages/nostr-client/src/events.ts @@ -0,0 +1,115 @@ +/** + * Event creation utilities for Lamassu ATM + */ + +import { type Event, type UnsignedEvent, finalizeEvent, getEventHash } from 'nostr-tools' +import { encryptContent } from './encryption.js' +import { + type MachineIdentity, + type MachineStatus, + type TransactionRecord, + LamassuEventKind, +} from './types.js' + +/** + * Create a signed event + */ +export function createSignedEvent( + identity: MachineIdentity, + event: Omit +): Event { + const unsigned: UnsignedEvent = { + ...event, + pubkey: identity.publicKey, + } + + return finalizeEvent(unsigned, identity.privateKey) +} + +/** + * Create a machine status event (Kind 30078) + * + * This is a replaceable event that represents the current machine state. + * Content is encrypted with NIP-44 for the operator. + */ +export function createMachineStatusEvent( + identity: MachineIdentity, + operatorPubkey: string, + status: MachineStatus +): Event { + const encryptedContent = encryptContent(identity, operatorPubkey, status) + + return createSignedEvent(identity, { + kind: LamassuEventKind.MachineStatus, + content: encryptedContent, + tags: [ + ['d', 'status'], + ['p', operatorPubkey], + ], + created_at: Math.floor(Date.now() / 1000), + }) +} + +/** + * Create a transaction record event (Kind 30079) + * + * Replaceable event for each transaction, identified by txid. + * Content is encrypted with NIP-44 for the operator. + */ +export function createTransactionEvent( + identity: MachineIdentity, + operatorPubkey: string, + transaction: TransactionRecord +): Event { + const encryptedContent = encryptContent(identity, operatorPubkey, transaction) + + return createSignedEvent(identity, { + kind: LamassuEventKind.TransactionRecord, + content: encryptedContent, + tags: [ + ['d', `tx:${transaction.txid}`], + ['p', operatorPubkey], + ], + created_at: Math.floor(Date.now() / 1000), + }) +} + +/** + * Create a NIP-42 auth event for relay authentication + */ +export function createAuthEvent( + identity: MachineIdentity, + relayUrl: string, + challenge: string +): Event { + return createSignedEvent(identity, { + kind: LamassuEventKind.Auth, + content: '', + tags: [ + ['relay', relayUrl], + ['challenge', challenge], + ], + created_at: Math.floor(Date.now() / 1000), + }) +} + +/** + * Validate an event signature + */ +export function validateEvent(event: Event): boolean { + try { + const hash = getEventHash(event) + return hash === event.id + } catch { + return false + } +} + +/** + * Generate a unique transaction ID + */ +export function generateTxId(): string { + const timestamp = Date.now().toString(36) + const random = Math.random().toString(36).substring(2, 10) + return `${timestamp}-${random}` +} diff --git a/lamassu-next/packages/nostr-client/src/identity.ts b/lamassu-next/packages/nostr-client/src/identity.ts new file mode 100644 index 0000000..0935a00 --- /dev/null +++ b/lamassu-next/packages/nostr-client/src/identity.ts @@ -0,0 +1,115 @@ +/** + * Machine identity management + * + * Each ATM has a Nostr keypair that serves as its cryptographic identity. + * This replaces traditional certificate-based authentication. + */ + +import { generateSecretKey, getPublicKey, nip19 } from 'nostr-tools' +import type { MachineIdentity } from './types.js' + +/** + * Generate a new machine identity (keypair) + */ +export function generateIdentity(): MachineIdentity { + const privateKey = generateSecretKey() + const publicKey = getPublicKey(privateKey) + const npub = nip19.npubEncode(publicKey) + + return { + privateKey, + publicKey, + npub, + } +} + +/** + * Load identity from hex-encoded private key + */ +export function loadIdentityFromHex(privateKeyHex: string): MachineIdentity { + const privateKey = hexToBytes(privateKeyHex) + const publicKey = getPublicKey(privateKey) + const npub = nip19.npubEncode(publicKey) + + return { + privateKey, + publicKey, + npub, + } +} + +/** + * Load identity from nsec (bech32-encoded private key) + */ +export function loadIdentityFromNsec(nsec: string): MachineIdentity { + const decoded = nip19.decode(nsec) + if (decoded.type !== 'nsec') { + throw new Error('Invalid nsec format') + } + + const privateKey = decoded.data + const publicKey = getPublicKey(privateKey) + const npub = nip19.npubEncode(publicKey) + + return { + privateKey, + publicKey, + npub, + } +} + +/** + * Export identity to nsec (for secure storage) + */ +export function exportToNsec(identity: MachineIdentity): string { + return nip19.nsecEncode(identity.privateKey) +} + +/** + * Parse a public key from various formats + * Accepts: hex, npub, nprofile + */ +export function parsePublicKey(input: string): string { + // Already hex format (64 chars) + if (/^[0-9a-f]{64}$/i.test(input)) { + return input.toLowerCase() + } + + // Try to decode as bech32 + try { + const decoded = nip19.decode(input) + switch (decoded.type) { + case 'npub': + return decoded.data + case 'nprofile': + return decoded.data.pubkey + default: + throw new Error(`Unsupported format: ${decoded.type}`) + } + } catch { + throw new Error('Invalid public key format') + } +} + +/** + * Convert hex string to Uint8Array + */ +function hexToBytes(hex: string): Uint8Array { + if (hex.length % 2 !== 0) { + throw new Error('Invalid hex string') + } + 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 +} + +/** + * Convert Uint8Array to hex string + */ +export function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map((b) => b.toString(16).padStart(2, '0')) + .join('') +} diff --git a/lamassu-next/packages/nostr-client/src/index.ts b/lamassu-next/packages/nostr-client/src/index.ts new file mode 100644 index 0000000..183811d --- /dev/null +++ b/lamassu-next/packages/nostr-client/src/index.ts @@ -0,0 +1,90 @@ +/** + * @lamassu/nostr-client + * + * Nostr client library for Lamassu ATM communication. + * + * Features: + * - NIP-42 authentication for private relays + * - NIP-44 encryption for sensitive data + * - Machine identity management + * - Event publishing and subscription + * - Automatic reconnection + * + * @example + * ```typescript + * import { + * NostrClient, + * generateIdentity, + * createMachineStatusEvent + * } from '@lamassu/nostr-client' + * + * // Create or load identity + * const identity = generateIdentity() + * + * // Create client + * const client = new NostrClient({ + * relays: [ + * { url: 'wss://relay.youratm.company', requiresAuth: true } + * ], + * identity + * }) + * + * // Connect + * await client.connect() + * + * // Publish machine status + * const statusEvent = createMachineStatusEvent( + * identity, + * operatorPubkey, + * { online: true, ... } + * ) + * await client.publish(statusEvent) + * ``` + */ + +// Main client +export { NostrClient } from './client.js' + +// Identity management +export { + generateIdentity, + loadIdentityFromHex, + loadIdentityFromNsec, + exportToNsec, + parsePublicKey, + bytesToHex, +} from './identity.js' + +// Event creation +export { + createSignedEvent, + createMachineStatusEvent, + createTransactionEvent, + createAuthEvent, + validateEvent, + generateTxId, +} from './events.js' + +// Encryption +export { encryptContent, decryptContent, decryptJSON } from './encryption.js' + +// Types +export type { + ConnectionState, + RelayConfig, + MachineIdentity, + NostrClientConfig, + SubscriptionFilter, + EventHandler, + EoseHandler, + SubscriptionOptions, + MachineStatus, + TransactionRecord, + OperatorCommand, + ClientEvents, +} from './types.js' + +export { LamassuEventKind } from './types.js' + +// Re-export useful nostr-tools types +export type { Event, UnsignedEvent, Filter } from 'nostr-tools' diff --git a/lamassu-next/packages/nostr-client/src/types.ts b/lamassu-next/packages/nostr-client/src/types.ts new file mode 100644 index 0000000..068a9e2 --- /dev/null +++ b/lamassu-next/packages/nostr-client/src/types.ts @@ -0,0 +1,147 @@ +/** + * Nostr client type definitions for Lamassu ATM + */ + +import type { Event, UnsignedEvent } from 'nostr-tools' + +/** Connection states for relay */ +export type ConnectionState = + | 'disconnected' + | 'connecting' + | 'connected' + | 'authenticating' + | 'authenticated' + | 'error' + +/** Relay configuration */ +export interface RelayConfig { + /** WebSocket URL (wss:// or ws://) */ + url: string + /** Whether this relay requires NIP-42 authentication */ + requiresAuth?: boolean + /** Read-only relay (no publishing) */ + readOnly?: boolean +} + +/** Machine identity configuration */ +export interface MachineIdentity { + /** Private key in hex format */ + privateKey: Uint8Array + /** Public key in hex format */ + publicKey: string + /** Public key in npub format */ + npub: string +} + +/** Client configuration */ +export interface NostrClientConfig { + /** Relays to connect to */ + relays: RelayConfig[] + /** Machine identity (keypair) */ + identity: MachineIdentity + /** Connection timeout in ms (default: 10000) */ + connectionTimeout?: number + /** Reconnect automatically on disconnect */ + autoReconnect?: boolean + /** Max reconnection attempts (default: 5) */ + maxReconnectAttempts?: number +} + +/** Subscription filter */ +export interface SubscriptionFilter { + /** Event IDs to match */ + ids?: string[] + /** Authors (pubkeys) to match */ + authors?: string[] + /** Event kinds to match */ + kinds?: number[] + /** Tags to match (#e, #p, etc.) */ + '#e'?: string[] + '#p'?: string[] + '#d'?: string[] + /** Only events after this timestamp */ + since?: number + /** Only events before this timestamp */ + until?: number + /** Maximum number of events */ + limit?: number +} + +/** Event handler callback */ +export type EventHandler = (event: Event) => void | Promise + +/** End of stored events callback */ +export type EoseHandler = () => void + +/** Subscription options */ +export interface SubscriptionOptions { + /** Handler for each event */ + onEvent: EventHandler + /** Handler when end of stored events reached */ + onEose?: EoseHandler + /** Close subscription after EOSE */ + closeOnEose?: boolean +} + +/** ATM-specific event kinds */ +export enum LamassuEventKind { + /** CLINK Offer Request/Response */ + ClinkOffer = 21001, + /** CLINK Debit Request/Response */ + ClinkDebit = 21002, + /** CLINK Management */ + ClinkManage = 21003, + /** Machine status (replaceable) */ + MachineStatus = 30078, + /** Transaction record (replaceable) */ + TransactionRecord = 30079, + /** NIP-17 Direct Message */ + DirectMessage = 14, + /** NIP-17 Gift Wrap */ + GiftWrap = 1059, + /** NIP-42 Auth */ + Auth = 22242, +} + +/** Machine status content (encrypted) */ +export interface MachineStatus { + online: boolean + lastTransaction: number + cashLevels: { + validator: number + dispenser: Array<{ + denomination: number + count: number + capacity: number + }> + } + errors: string[] + version: string +} + +/** Transaction record content (encrypted) */ +export interface TransactionRecord { + txid: string + type: 'cash_in' | 'cash_out' + amountFiat: number + amountSats: number + fee: number + timestamp: number + paymentMethod: 'lnurl_withdraw' | 'clink_offer' | 'invoice' | 'cashu' +} + +/** Operator command content (encrypted) */ +export interface OperatorCommand { + command: 'restart' | 'update' | 'disable' | 'enable' | 'set_limits' + params?: Record + timestamp: number +} + +/** Events emitted by the client */ +export interface ClientEvents { + connect: { relay: string } + disconnect: { relay: string; reason?: string } + auth: { relay: string; success: boolean } + error: { relay: string; error: Error } + event: { relay: string; event: Event } +} diff --git a/lamassu-next/packages/nostr-client/tsconfig.json b/lamassu-next/packages/nostr-client/tsconfig.json new file mode 100644 index 0000000..d770c39 --- /dev/null +++ b/lamassu-next/packages/nostr-client/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "strictNullChecks": true, + "noUncheckedIndexedAccess": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/lamassu-next/packages/nostr-client/vitest.config.ts b/lamassu-next/packages/nostr-client/vitest.config.ts new file mode 100644 index 0000000..9fb4f14 --- /dev/null +++ b/lamassu-next/packages/nostr-client/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + include: ['src/**/*.test.ts'], + globals: false, + }, +}) diff --git a/lamassu-next/pnpm-lock.yaml b/lamassu-next/pnpm-lock.yaml index 7db959e..c2a1a19 100644 --- a/lamassu-next/pnpm-lock.yaml +++ b/lamassu-next/pnpm-lock.yaml @@ -83,10 +83,16 @@ importers: nostr-tools: specifier: ^2.10.0 version: 2.19.4(typescript@5.9.3) + ws: + specifier: ^8.18.0 + version: 8.19.0 devDependencies: '@types/node': specifier: ^22.0.0 version: 22.19.7 + '@types/ws': + specifier: ^8.5.13 + version: 8.18.1 tsx: specifier: ^4.19.0 version: 4.21.0 @@ -793,6 +799,9 @@ packages: '@types/node@22.19.7': resolution: {integrity: sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + '@vitejs/plugin-vue@5.2.4': resolution: {integrity: sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1246,6 +1255,18 @@ packages: engines: {node: '>=8'} hasBin: true + ws@8.19.0: + resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + xstate@5.25.1: resolution: {integrity: sha512-oyvsNH5pF2qkHmiHEMdWqc3OjDtoZOH2MTAI35r01f/ZQWOD+VLOiYqo65UgQET0XMA5s9eRm8fnsIo+82biEw==} @@ -1625,6 +1646,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/ws@8.18.1': + dependencies: + '@types/node': 22.19.7 + '@vitejs/plugin-vue@5.2.4(vite@6.4.1(@types/node@22.19.7)(tsx@4.21.0))(vue@3.5.27(typescript@5.9.3))': dependencies: vite: 6.4.1(@types/node@22.19.7)(tsx@4.21.0) @@ -2135,4 +2160,6 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + ws@8.19.0: {} + xstate@5.25.1: {}