#!/usr/bin/env node /** * Superball Thrower Daemon * * A standalone Node.js daemon implementation of the Superball Thrower functionality * extracted from the web-based thrower.html implementation. * * This daemon handles: * - NOSTR event monitoring and processing * - NIP-44 encryption/decryption * - Superball protocol routing (SUP-01 through SUP-06) * - Event queue management with delayed processing * - Relay management and authentication testing * - Thrower Information Document publishing (SUP-06) * - Privacy-focused event forwarding with padding */ const WebSocket = require('ws'); const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); // Import NOSTR tools - these will be installed via npm const { SimplePool, generateSecretKey, getPublicKey, finalizeEvent, nip44 } = require('nostr-tools'); // Configuration class ThrowerConfig { constructor(configPath = './config.json') { this.configPath = configPath; this.config = this.loadConfig(); } loadConfig() { try { if (fs.existsSync(this.configPath)) { const configData = fs.readFileSync(this.configPath, 'utf8'); return JSON.parse(configData); } } catch (error) { console.error(`Error loading config: ${error.message}`); } // Default configuration return { thrower: { privateKey: null, // Will be generated if not provided name: 'My Superball Thrower', description: 'A privacy-focused Superball Thrower node', banner: '', icon: '', adminPubkey: '', contact: '', supportedSups: '1,2,3,4,5,6', software: 'https://git.laantungir.net/laantungir/super_ball.git', version: '1.0.0', privacyPolicy: '', termsOfService: '', refreshRate: 300, maxDelay: 86460 }, relays: [ { url: 'wss://relay.laantungir.net', type: '', authStatus: 'unknown' } ], daemon: { logLevel: 'info', // debug, info, warn, error maxQueueSize: 1000, maxLogEntries: 1000, autoStart: false } }; } saveConfig() { try { fs.writeFileSync(this.configPath, JSON.stringify(this.config, null, 2)); return true; } catch (error) { console.error(`Error saving config: ${error.message}`); return false; } } get(key) { return key.split('.').reduce((obj, k) => obj && obj[k], this.config); } set(key, value) { const keys = key.split('.'); const lastKey = keys.pop(); const target = keys.reduce((obj, k) => obj[k] = obj[k] || {}, this.config); target[lastKey] = value; } } // NOSTR Utilities class NostrUtils { static generateKeyPair() { const privateKey = generateSecretKey(); const publicKey = getPublicKey(privateKey); return { privateKey, publicKey }; } static bytesToHex(bytes) { return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join(''); } static hexToBytes(hex) { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = parseInt(hex.substr(i, 2), 16); } return bytes; } static async encryptNip44(content, senderPrivateKey, recipientPublicKey) { const senderPrivateKeyHex = typeof senderPrivateKey === 'string' ? senderPrivateKey : this.bytesToHex(senderPrivateKey); const conversationKey = nip44.v2.utils.getConversationKey( senderPrivateKeyHex, recipientPublicKey ); return nip44.v2.encrypt(content, conversationKey); } static async decryptNip44(encryptedContent, recipientPrivateKey, senderPublicKey) { const recipientPrivateKeyHex = typeof recipientPrivateKey === 'string' ? recipientPrivateKey : this.bytesToHex(recipientPrivateKey); const conversationKey = nip44.v2.utils.getConversationKey( recipientPrivateKeyHex, senderPublicKey ); return nip44.v2.decrypt(encryptedContent, conversationKey); } } // Relay Management class RelayManager { constructor(config, logger) { this.config = config; this.logger = logger; this.relays = config.get('relays') || []; } async testRelayAuthentication(relayUrl) { return new Promise((resolve) => { const timeout = setTimeout(() => { this.logger.error(`Auth test timed out for ${relayUrl} (10s timeout)`); ws.close(); resolve('error'); }, 10000); const ws = new WebSocket(relayUrl); let authRequired = false; let publishAttempted = false; ws.on('open', () => { this.logger.info(`Connected to ${relayUrl} for auth test`); // Generate test event with random key const testKey = generateSecretKey(); const testEvent = { kind: 1, content: `Auth test ${Date.now()}`, tags: [], created_at: Math.floor(Date.now() / 1000) }; const signedEvent = finalizeEvent(testEvent, testKey); // Attempt to publish const publishMsg = JSON.stringify(['EVENT', signedEvent]); ws.send(publishMsg); publishAttempted = true; this.logger.info(`Sent test event ${signedEvent.id.substring(0, 16)}... to ${relayUrl}`); }); ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); this.logger.debug(`Response from ${relayUrl}: ${JSON.stringify(message)}`); if (message[0] === 'AUTH') { authRequired = true; this.logger.info(`${relayUrl} requires AUTH - marking as read-only`); clearTimeout(timeout); ws.close(); resolve('auth-required'); } else if (message[0] === 'OK') { if (publishAttempted && !authRequired) { const accepted = message[2]; const reason = message[3] || ''; if (accepted) { this.logger.info(`${relayUrl} accepted test event - read/write capable`); clearTimeout(timeout); ws.close(); resolve('no-auth'); } else { this.logger.info(`${relayUrl} rejected test event: "${reason}" - marking as read-only`); clearTimeout(timeout); ws.close(); resolve('auth-required'); } } } else if (message[0] === 'NOTICE') { const notice = message[1] || ''; this.logger.info(`Notice from ${relayUrl}: "${notice}"`); if (notice.toLowerCase().includes('auth')) { authRequired = true; this.logger.info(`${relayUrl} notice indicates auth required`); clearTimeout(timeout); ws.close(); resolve('auth-required'); } } } catch (error) { this.logger.error(`Error parsing message from ${relayUrl}: ${error.message}`); } }); ws.on('error', (error) => { this.logger.error(`WebSocket error with ${relayUrl}: ${error.message}`); clearTimeout(timeout); resolve('error'); }); ws.on('close', (code, reason) => { this.logger.info(`Connection closed to ${relayUrl} - Code: ${code}, Reason: ${reason}`); clearTimeout(timeout); if (!authRequired && publishAttempted) { this.logger.info(`${relayUrl} closed without clear response - assuming no auth required`); resolve('no-auth'); } }); }); } async testAllRelays() { this.logger.info(`Testing authentication for ${this.relays.length} relays...`); const testPromises = this.relays.map(async (relay, index) => { try { relay.authStatus = 'testing'; const result = await this.testRelayAuthentication(relay.url); relay.authStatus = result; relay.lastTested = Date.now(); return result; } catch (error) { this.logger.error(`Failed to test relay ${relay.url}: ${error.message}`); relay.authStatus = 'error'; relay.lastTested = Date.now(); return 'error'; } }); await Promise.allSettled(testPromises); const results = this.relays.map(r => r.authStatus); const readWriteCount = results.filter(r => r === 'no-auth').length; const readOnlyCount = results.filter(r => r === 'auth-required').length; const errorCount = results.filter(r => r === 'error').length; this.logger.info(`Relay auth testing complete: ${readWriteCount} read/write, ${readOnlyCount} read-only, ${errorCount} errors`); // Update relay capabilities based on auth status this.relays.forEach(relay => { if (relay.authStatus === 'auth-required') { // Can only read from auth-required relays relay.write = false; // Keep read capability as configured } else if (relay.authStatus === 'no-auth') { // Keep both read and write capabilities as configured } else if (relay.authStatus === 'error') { // Disable both read and write for error relays relay.read = false; relay.write = false; } }); return { readWriteCount, readOnlyCount, errorCount }; } getWriteCapableRelays() { return this.relays.filter(relay => relay.write && (relay.authStatus === 'no-auth' || relay.authStatus === 'unknown') ); } getReadCapableRelays() { return this.relays.filter(relay => relay.read && relay.authStatus !== 'error' ); } } // Event Processing class EventProcessor { constructor(config, logger, relayManager) { this.config = config; this.logger = logger; this.relayManager = relayManager; this.eventQueue = []; this.processedEventIds = new Set(); this.processedEvents = 0; } async handleIncomingEvent(event) { // Deduplication check if (this.processedEventIds.has(event.id)) { this.logger.info(`Skipping duplicate event: ${event.id.substring(0, 16)}... (already processed)`); return; } this.processedEventIds.add(event.id); this.logger.info(`Received routing event: ${event.id.substring(0, 16)}...`); try { // Decrypt the event payload let decryptedPayload = await this.decryptRoutingEvent(event); if (!decryptedPayload) { this.logger.error(`Failed to decrypt event ${event.id.substring(0, 16)}...`); return; } this.logger.info(`First decryption successful for event ${event.id.substring(0, 16)}...`); // Check payload type according to DAEMON.md protocol if (decryptedPayload.padding !== undefined) { this.logger.info(`Detected Type 2 (Padding Payload) - discarding padding and performing second decryption`); const innerEvent = decryptedPayload.event; this.logger.info(`Discarding padding: "${decryptedPayload.padding}"`); // Second decryption to get the actual routing instructions decryptedPayload = await this.decryptRoutingEvent(innerEvent); if (!decryptedPayload) { this.logger.error(`Failed to decrypt inner event ${innerEvent.id.substring(0, 16)}...`); return; } this.logger.info(`Second decryption successful - found original routing instructions from builder`); } else { this.logger.info(`Detected Type 1 (Routing Payload) - processing routing instructions directly`); } this.logger.debug(`Final routing payload: ${JSON.stringify(decryptedPayload, null, 2)}`); // Parse routing instructions const { event: wrappedEvent, routing } = decryptedPayload; if (!routing) { this.logger.error(`No routing instructions found in final payload for ${event.id.substring(0, 16)}...`); return; } if (!this.validateRoutingInstructions(routing)) { this.logger.error(`Invalid routing instructions in event ${event.id.substring(0, 16)}...`); return; } // Create queue item const queueItem = { id: event.id, wrappedEvent, routing, receivedAt: Date.now(), processAt: Date.now() + (routing.delay * 1000), status: 'queued' }; this.eventQueue.push(queueItem); this.logger.info(`Event queued for processing in ${routing.delay}s: ${event.id.substring(0, 16)}...`); // Schedule processing setTimeout(() => this.processQueuedEvent(queueItem), routing.delay * 1000); } catch (error) { this.logger.error(`Error processing event ${event.id.substring(0, 16)}...: ${error.message}`); } } async decryptRoutingEvent(event) { try { const privateKeyHex = this.config.get('thrower.privateKey'); if (!privateKeyHex) { throw new Error('No private key configured for decryption'); } this.logger.debug(`Attempting decryption of event ${event.id} from ${event.pubkey}`); const decrypted = await NostrUtils.decryptNip44( event.content, privateKeyHex, event.pubkey ); this.logger.debug(`Decryption successful! Decrypted length: ${decrypted.length}`); return JSON.parse(decrypted); } catch (error) { this.logger.error(`Decryption failed: ${error.message}`); return null; } } validateRoutingInstructions(routing) { if (!routing || typeof routing !== 'object') return false; if (!Array.isArray(routing.relays) || routing.relays.length === 0) return false; if (typeof routing.delay !== 'number' || routing.delay < 0) return false; if (!routing.audit || typeof routing.audit !== 'string') return false; // Check maximum delay limit const maxDelay = this.config.get('thrower.maxDelay') || 86460; if (routing.delay > maxDelay) { this.logger.error(`Routing delay ${routing.delay}s exceeds maximum allowed delay of ${maxDelay}s`); return false; } return true; } async processQueuedEvent(queueItem) { this.logger.info(`Processing event ${queueItem.id.substring(0, 16)}...`); queueItem.status = 'processing'; try { const { wrappedEvent, routing } = queueItem; this.logger.debug(`Decision point - routing.p = "${routing.p}" (${typeof routing.p})`); if (routing.p) { // Continue routing to next Superball with padding wrapper await this.forwardToNextSuperball(wrappedEvent, routing); } else { // Final posting - post the wrapped event directly await this.postFinalEvent(wrappedEvent, routing.relays); } this.processedEvents++; // Remove from queue const index = this.eventQueue.findIndex(item => item.id === queueItem.id); if (index !== -1) { this.eventQueue.splice(index, 1); } this.logger.info(`Successfully processed event ${queueItem.id.substring(0, 16)}...`); } catch (error) { this.logger.error(`Failed to process event ${queueItem.id.substring(0, 16)}...: ${error.message}`); queueItem.status = 'failed'; } } async forwardToNextSuperball(event, routing) { this.logger.info(`Forwarding to next Superball: ${routing.p.substring(0, 16)}...`); // Create new ephemeral keypair const ephemeralKey = generateSecretKey(); const ephemeralPubkey = getPublicKey(ephemeralKey); // Generate padding based on add_padding_bytes instruction let paddingData = ''; if (routing.add_padding_bytes && routing.add_padding_bytes > 0) { const chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; for (let i = 0; i < routing.add_padding_bytes; i++) { paddingData += chars.charAt(Math.floor(Math.random() * chars.length)); } this.logger.info(`Generated ${paddingData.length} bytes of padding`); } // Create padding-only payload const paddingPayload = { event: event, // This is the still-encrypted inner event padding: paddingData // Padding to discard }; this.logger.debug(`Creating padding payload with ${paddingData.length} bytes of padding`); // Encrypt padding payload to next Superball const ephemeralKeyHex = NostrUtils.bytesToHex(ephemeralKey); const encryptedContent = await NostrUtils.encryptNip44( JSON.stringify(paddingPayload), ephemeralKeyHex, routing.p ); // Create new routing event (forwarding to next hop) const routingEvent = { kind: 22222, content: encryptedContent, tags: [ ['p', routing.p], // Next Superball ['p', routing.audit] // Audit tag (camouflage) ], created_at: Math.floor(Date.now() / 1000) }; // Sign with ephemeral key const signedEvent = finalizeEvent(routingEvent, ephemeralKey); this.logger.debug(`Padding-wrapped event to publish: ${JSON.stringify(signedEvent, null, 2)}`); // Publish to specified relays await this.publishToRelays(signedEvent, routing.relays); this.logger.info(`Forwarded with padding layer to ${routing.p.substring(0, 16)}... (audit: ${routing.audit.substring(0, 16)}...)`); } async postFinalEvent(event, relays) { this.logger.info(`Posting final event to ${relays.length} relays`); await this.publishToRelays(event, relays); this.logger.info(`Final event posted to relays: ${event.id.substring(0, 16)}...`); } async publishToRelays(event, relays) { const pool = new SimplePool(); // Filter to only use relays that don't require auth (write-capable) const writeRelays = relays.filter(relayUrl => { const relay = this.relayManager.relays.find(r => r.url === relayUrl); if (!relay) return true; // Default to allowing if relay not found return relay.authStatus === 'no-auth' || relay.authStatus === 'unknown'; }); if (writeRelays.length === 0) { throw new Error('No write-capable (non-AUTH) relays available for publishing'); } if (writeRelays.length < relays.length) { const skippedCount = relays.length - writeRelays.length; this.logger.info(`Skipping ${skippedCount} AUTH-required relays, using ${writeRelays.length} write-capable relays`); } // Publish to each relay individually const publishPromises = writeRelays.map(relayUrl => { const promises = pool.publish([relayUrl], event); return promises[0] .then(result => ({ relayUrl, success: true, result })) .catch(error => ({ relayUrl, success: false, error: error.message })); }); try { const results = await Promise.allSettled(publishPromises); let successCount = 0; let failureCount = 0; const successfulRelays = []; const failedRelays = []; results.forEach((promiseResult, index) => { if (promiseResult.status === 'fulfilled') { const { relayUrl, success, result, error } = promiseResult.value; if (success) { successCount++; successfulRelays.push(relayUrl); this.logger.info(`✅ Published successfully to ${relayUrl}`); } else { failureCount++; failedRelays.push({ relayUrl, error }); this.logger.error(`❌ Failed to publish to ${relayUrl}: ${error}`); } } else { const relayUrl = writeRelays[index]; failureCount++; failedRelays.push({ relayUrl, error: promiseResult.reason?.message || 'Unknown error' }); this.logger.error(`❌ Failed to publish to ${relayUrl}: ${promiseResult.reason?.message || 'Unknown error'}`); } }); this.logger.info(`Publishing summary: ${successCount} successful, ${failureCount} failed out of ${writeRelays.length} total relays`); if (successCount > 0) { this.logger.info(`Successfully published to: ${successfulRelays.join(', ')}`); this.logger.debug(`Full published event: ${JSON.stringify(event, null, 2)}`); } if (failureCount > 0) { const failureDetails = failedRelays.map(f => `${f.relayUrl}: ${f.error}`).join(', '); this.logger.error(`Failed to publish to: ${failureDetails}`); } if (successCount === 0) { throw new Error(`Failed to publish to any write-capable relay. Attempted: ${writeRelays.join(', ')}`); } } finally { pool.close(writeRelays); } } getQueueStatus() { return { queueLength: this.eventQueue.length, processedEvents: this.processedEvents, queueItems: this.eventQueue.map(item => ({ id: item.id.substring(0, 16) + '...', status: item.status, timeLeft: Math.max(0, Math.ceil((item.processAt - Date.now()) / 1000)), relayCount: item.routing.relays.length, delay: item.routing.delay, nextHop: item.routing.p ? item.routing.p.substring(0, 16) + '...' : 'Final Posting' })) }; } } // Thrower Information Document Manager (SUP-06) class ThrowerInfoManager { constructor(config, logger, relayManager) { this.config = config; this.logger = logger; this.relayManager = relayManager; this.refreshInterval = null; this.lastPublish = null; } async publishThrowerInfo() { const privateKeyHex = this.config.get('thrower.privateKey'); if (!privateKeyHex) { throw new Error('No private key configured for publishing'); } const throwerInfo = this.config.get('thrower'); // Build tags array according to SUP-06 const tags = []; if (throwerInfo.name) tags.push(['name', throwerInfo.name]); if (throwerInfo.description) tags.push(['description', throwerInfo.description]); if (throwerInfo.banner) tags.push(['banner', throwerInfo.banner]); if (throwerInfo.icon) tags.push(['icon', throwerInfo.icon]); if (throwerInfo.adminPubkey) tags.push(['pubkey', throwerInfo.adminPubkey]); if (throwerInfo.contact) tags.push(['contact', throwerInfo.contact]); if (throwerInfo.supportedSups) tags.push(['supported_sups', throwerInfo.supportedSups]); if (throwerInfo.software) tags.push(['software', throwerInfo.software]); if (throwerInfo.version) tags.push(['version', throwerInfo.version]); if (throwerInfo.privacyPolicy) tags.push(['privacy_policy', throwerInfo.privacyPolicy]); if (throwerInfo.termsOfService) tags.push(['terms_of_service', throwerInfo.termsOfService]); tags.push(['refresh_rate', throwerInfo.refreshRate.toString()]); tags.push(['max_delay', (throwerInfo.maxDelay || 86460).toString()]); const eventTemplate = { kind: 12222, content: throwerInfo.content || '', tags: tags, created_at: Math.floor(Date.now() / 1000) }; const privateKeyBytes = NostrUtils.hexToBytes(privateKeyHex); const signedEvent = finalizeEvent(eventTemplate, privateKeyBytes); this.logger.debug(`Thrower Info event to publish: ${JSON.stringify(signedEvent, null, 2)}`); // Publish to write-capable relays const writeRelays = this.relayManager.getWriteCapableRelays().map(r => r.url); if (writeRelays.length === 0) { throw new Error('No write-capable relays available for publishing Thrower Info'); } const pool = new SimplePool(); try { const publishPromises = writeRelays.map(relayUrl => { const promises = pool.publish([relayUrl], signedEvent); return promises[0] .then(() => ({ relayUrl, success: true })) .catch(error => ({ relayUrl, success: false, error: error.message })); }); const results = await Promise.allSettled(publishPromises); let successCount = 0; results.forEach((promiseResult) => { if (promiseResult.status === 'fulfilled') { const { relayUrl, success, error } = promiseResult.value; if (success) { successCount++; this.logger.info(`✅ Thrower info published successfully to ${relayUrl}`); } else { this.logger.error(`❌ Failed to publish thrower info to ${relayUrl}: ${error}`); } } }); if (successCount === 0) { throw new Error(`Failed to publish thrower info to any relay (attempted: ${writeRelays.join(', ')})`); } this.logger.info(`Thrower info published to ${successCount} out of ${writeRelays.length} relays`); this.lastPublish = signedEvent.created_at; } finally { pool.close(writeRelays); } // Also publish relay list (NIP-65) so web interface can show relay configuration await this.publishRelayList(); } async publishRelayList() { const privateKeyHex = this.config.get('thrower.privateKey'); if (!privateKeyHex) { throw new Error('No private key configured for publishing relay list'); } this.logger.info('Publishing relay list (NIP-65) for web interface compatibility...'); const relays = this.config.get('relays') || []; const relayTags = []; // Add relay tags based on configuration and authentication status relays.forEach(relay => { if (relay.authStatus === 'error') { // Skip relays that are completely broken return; } // Add read capability if configured and relay is functional if (relay.read && relay.authStatus !== 'error') { relayTags.push(['r', relay.url, 'read']); } // Add write capability if configured and relay supports it if (relay.write && (relay.authStatus === 'no-auth' || relay.authStatus === 'unknown')) { relayTags.push(['r', relay.url, 'write']); } }); if (relayTags.length === 0) { this.logger.warn('No relays available for relay list - skipping relay list publication'); return; } const relayListEvent = { kind: 10002, content: '', tags: relayTags, created_at: Math.floor(Date.now() / 1000) }; const privateKeyBytes = NostrUtils.hexToBytes(privateKeyHex); const signedEvent = finalizeEvent(relayListEvent, privateKeyBytes); this.logger.debug(`Relay list event to publish: ${JSON.stringify(signedEvent, null, 2)}`); // Publish to write-capable relays const writeRelays = this.relayManager.getWriteCapableRelays().map(r => r.url); if (writeRelays.length === 0) { this.logger.warn('No write-capable relays available for publishing relay list'); return; } const pool = new SimplePool(); try { const publishPromises = writeRelays.map(relayUrl => { const promises = pool.publish([relayUrl], signedEvent); return promises[0] .then(() => ({ relayUrl, success: true })) .catch(error => ({ relayUrl, success: false, error: error.message })); }); const results = await Promise.allSettled(publishPromises); let successCount = 0; results.forEach((promiseResult) => { if (promiseResult.status === 'fulfilled') { const { relayUrl, success, error } = promiseResult.value; if (success) { successCount++; this.logger.info(`✅ Relay list published successfully to ${relayUrl}`); } else { this.logger.error(`❌ Failed to publish relay list to ${relayUrl}: ${error}`); } } }); if (successCount === 0) { this.logger.warn(`Failed to publish relay list to any relay (attempted: ${writeRelays.join(', ')})`); } else { this.logger.info(`Relay list published to ${successCount} out of ${writeRelays.length} relays`); } } finally { pool.close(writeRelays); } } startAutoPublish() { const refreshRate = this.config.get('thrower.refreshRate') || 300; if (this.refreshInterval) { clearInterval(this.refreshInterval); } // Schedule republishing 10 seconds before refresh rate expires const intervalMs = Math.max(10000, (refreshRate - 10) * 1000); this.logger.info(`Starting Thrower Info auto-publish every ${intervalMs / 1000} seconds`); this.refreshInterval = setInterval(async () => { try { this.logger.info('Auto-publishing Thrower Information Document...'); await this.publishThrowerInfo(); this.logger.info('Thrower Info auto-published successfully'); } catch (error) { this.logger.error(`Auto-publish failed: ${error.message}`); } }, intervalMs); } stopAutoPublish() { if (this.refreshInterval) { clearInterval(this.refreshInterval); this.refreshInterval = null; this.logger.info('Stopped Thrower Info auto-publish'); } } } // WebSocket Connection Manager class WebSocketManager { constructor(config, logger, eventProcessor) { this.config = config; this.logger = logger; this.eventProcessor = eventProcessor; this.connections = []; this.subscriptionId = null; } async startMonitoring() { const publicKey = this.config.get('thrower.publicKey'); if (!publicKey) { throw new Error('No public key configured for monitoring'); } const relays = this.config.get('relays') || []; const monitoringRelays = relays.map(r => r.url); this.logger.info(`Connecting to ${monitoringRelays.length} relays via WebSocket for kind 22222 events`); // Generate unique subscription ID this.subscriptionId = 'superball_' + Date.now(); // Subscribe to kind 22222 events with p tag matching this node's pubkey const now = Math.floor(Date.now() / 1000); const subscriptionFilter = { kinds: [22222], '#p': [publicKey], since: now }; this.logger.info(`Subscription ID: ${this.subscriptionId}`); this.logger.debug(`Subscription filter: ${JSON.stringify(subscriptionFilter)}`); // Connect to each relay monitoringRelays.forEach((relayUrl) => { this.connectToRelay(relayUrl, subscriptionFilter); }); return monitoringRelays.length; } connectToRelay(relayUrl, subscriptionFilter) { this.logger.info(`Connecting to relay: ${relayUrl}`); const ws = new WebSocket(relayUrl); ws.on('open', () => { this.logger.info(`Connected to relay: ${relayUrl}`); // Send subscription request const reqMessage = JSON.stringify([ 'REQ', this.subscriptionId, subscriptionFilter ]); this.logger.debug(`Sending REQ to ${relayUrl}: ${reqMessage}`); ws.send(reqMessage); }); ws.on('message', (data) => { try { const message = JSON.parse(data.toString()); this.logger.debug(`Received from ${relayUrl}: ${JSON.stringify(message).substring(0, 200)}...`); // Handle different message types if (message[0] === 'EVENT' && message[1] === this.subscriptionId) { const nostrEvent = message[2]; this.logger.info(`Received EVENT from ${relayUrl}: ${nostrEvent.id.substring(0, 16)}...`); this.logger.debug(`Full received event: ${JSON.stringify(nostrEvent, null, 2)}`); this.eventProcessor.handleIncomingEvent(nostrEvent); } else if (message[0] === 'EOSE' && message[1] === this.subscriptionId) { this.logger.info(`End of stored events from ${relayUrl}`); } else if (message[0] === 'NOTICE') { this.logger.info(`Notice from ${relayUrl}: ${message[1]}`); } else if (message[0] === 'OK') { this.logger.debug(`OK response from ${relayUrl}: ${JSON.stringify(message)}`); } } catch (error) { this.logger.error(`Error parsing message from ${relayUrl}: ${error.message}`); } }); ws.on('error', (error) => { this.logger.error(`WebSocket error with ${relayUrl}: ${error.message}`); }); ws.on('close', (code, reason) => { this.logger.info(`Connection closed to ${relayUrl} - Code: ${code}, Reason: ${reason}`); }); this.connections.push({ url: relayUrl, ws }); } stopMonitoring() { this.logger.info('Stopping WebSocket monitoring...'); this.connections.forEach(({ url, ws }) => { if (ws.readyState === ws.OPEN) { // Send CLOSE message for subscription if (this.subscriptionId) { const closeMsg = JSON.stringify(['CLOSE', this.subscriptionId]); ws.send(closeMsg); this.logger.info(`Sent CLOSE to ${url}`); } ws.close(); } }); this.connections = []; this.subscriptionId = null; this.logger.info('WebSocket monitoring stopped'); } } // Logger class Logger { constructor(level = 'info') { this.level = level; this.levels = { debug: 0, info: 1, warn: 2, error: 3 }; } setLevel(level) { this.level = level; } log(level, message) { if (this.levels[level] >= this.levels[this.level]) { const timestamp = new Date().toISOString(); console.log(`[${timestamp}] [${level.toUpperCase()}] ${message}`); } } debug(message) { this.log('debug', message); } info(message) { this.log('info', message); } warn(message) { this.log('warn', message); } error(message) { this.log('error', message); } } // Main Thrower Daemon Class class ThrowerDaemon { constructor(configPath) { this.config = new ThrowerConfig(configPath); this.logger = new Logger(this.config.get('daemon.logLevel') || 'info'); this.relayManager = new RelayManager(this.config, this.logger); this.eventProcessor = new EventProcessor(this.config, this.logger, this.relayManager); this.throwerInfoManager = new ThrowerInfoManager(this.config, this.logger, this.relayManager); this.wsManager = new WebSocketManager(this.config, this.logger, this.eventProcessor); this.running = false; } async initialize() { this.logger.info('Initializing Superball Thrower Daemon...'); // Generate keypair if not configured let privateKey = this.config.get('thrower.privateKey'); if (!privateKey) { this.logger.info('No private key found, generating new keypair...'); const keyPair = NostrUtils.generateKeyPair(); privateKey = NostrUtils.bytesToHex(keyPair.privateKey); const publicKey = NostrUtils.bytesToHex(keyPair.publicKey); this.config.set('thrower.privateKey', privateKey); this.config.set('thrower.publicKey', publicKey); this.config.saveConfig(); this.logger.info(`Generated new keypair - Public key: ${publicKey}`); } else { // Derive public key from private key const privateKeyBytes = NostrUtils.hexToBytes(privateKey); const publicKey = getPublicKey(privateKeyBytes); this.config.set('thrower.publicKey', publicKey); this.logger.info(`Using existing keypair - Public key: ${publicKey}`); } this.logger.info('Daemon initialized successfully'); } async start() { if (this.running) { this.logger.warn('Daemon is already running'); return; } try { this.logger.info('Starting Superball Thrower Daemon...'); // Test relay authentication this.logger.info('Testing relay authentication capabilities...'); await this.relayManager.testAllRelays(); // Start WebSocket monitoring const relayCount = await this.wsManager.startMonitoring(); this.logger.info(`Monitoring ${relayCount} relays for routing events`); // Publish Thrower Info Document to announce availability try { this.logger.info('Publishing Thrower Information Document to announce availability...'); await this.throwerInfoManager.publishThrowerInfo(); this.logger.info('Thrower availability announced successfully'); } catch (error) { this.logger.error(`Failed to announce thrower availability: ${error.message}`); } // Start auto-publishing Thrower Info Document this.throwerInfoManager.startAutoPublish(); this.running = true; this.logger.info('Superball Thrower Daemon started successfully'); } catch (error) { this.logger.error(`Failed to start daemon: ${error.message}`); throw error; } } async stop() { if (!this.running) { this.logger.warn('Daemon is not running'); return; } this.logger.info('Stopping Superball Thrower Daemon...'); // Stop WebSocket monitoring this.wsManager.stopMonitoring(); // Stop auto-publishing this.throwerInfoManager.stopAutoPublish(); // Clear event queue this.eventProcessor.eventQueue = []; this.eventProcessor.processedEventIds.clear(); this.running = false; this.logger.info('Superball Thrower Daemon stopped successfully'); } getStatus() { const queueStatus = this.eventProcessor.getQueueStatus(); const relayStatus = { total: this.relayManager.relays.length, writeCapable: this.relayManager.getWriteCapableRelays().length, readCapable: this.relayManager.getReadCapableRelays().length }; return { running: this.running, publicKey: this.config.get('thrower.publicKey'), throwerName: this.config.get('thrower.name'), relays: relayStatus, queue: queueStatus, lastThrowerInfoPublish: this.throwerInfoManager.lastPublish }; } } // CLI Interface async function main() { const args = process.argv.slice(2); const command = args[0] || 'help'; const configPath = args.find(arg => arg.startsWith('--config='))?.split('=')[1] || './config.json'; const daemon = new ThrowerDaemon(configPath); switch (command) { case 'init': await daemon.initialize(); console.log('Daemon initialized. Edit config.json and run "start" to begin.'); break; case 'start': await daemon.initialize(); await daemon.start(); // Handle graceful shutdown process.on('SIGINT', async () => { console.log('\nReceived SIGINT, shutting down gracefully...'); await daemon.stop(); process.exit(0); }); process.on('SIGTERM', async () => { console.log('\nReceived SIGTERM, shutting down gracefully...'); await daemon.stop(); process.exit(0); }); // Keep the process running setInterval(() => { const status = daemon.getStatus(); if (status.running) { console.log(`[${new Date().toISOString()}] Status: Running | Queue: ${status.queue.queueLength} | Processed: ${status.queue.processedEvents}`); } }, 60000); // Status update every minute break; case 'stop': await daemon.stop(); break; case 'status': const status = daemon.getStatus(); console.log(JSON.stringify(status, null, 2)); break; case 'test-relays': await daemon.initialize(); const results = await daemon.relayManager.testAllRelays(); console.log(`Relay test results: ${results.readWriteCount} read/write, ${results.readOnlyCount} read-only, ${results.errorCount} errors`); break; case 'help': default: console.log(` Superball Thrower Daemon Usage: node daemon.js [options] Commands: init Initialize daemon with new keypair and default config start Start the thrower daemon stop Stop the thrower daemon status Show daemon status test-relays Test relay authentication capabilities help Show this help message Options: --config= Path to config file (default: ./config.json) Examples: node daemon.js init node daemon.js start node daemon.js start --config=/etc/thrower/config.json node daemon.js status `); break; } } // Run CLI if this file is executed directly if (require.main === module) { main().catch(error => { console.error('Fatal error:', error.message); process.exit(1); }); } module.exports = { ThrowerDaemon, ThrowerConfig, NostrUtils };