super_ball/thrower_daemon/daemon.js

1188 lines
39 KiB
JavaScript

#!/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') || 3600;
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 || 3600).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 === WebSocket.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 <command> [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> 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 };