import { Event, EventTemplate, finalizeEvent, nip19, SimplePool } from 'nostr-tools' import { hexToBytes } from 'nostr-tools/utils' import type { Article, NostrProfile } from '@/types/nostr' import { createSubscription } from '@/types/nostr-tools-extended' import { parseArticleFromEvent } from './nostrEventParsing' import { parsePresentationEvent } from './articlePublisherHelpersPresentation' import { getPrivateContent as getPrivateContentFromPool, getDecryptionKey, decryptArticleContentWithKey, } from './nostrPrivateMessages' import { checkZapReceipt as checkZapReceiptHelper } from './nostrZapVerification' import { subscribeWithTimeout } from './nostrSubscription' import { getPrimaryRelay, getPrimaryRelaySync } from './config' import { buildTagFilter } from './nostrTagSystem' import { PLATFORM_SERVICE, MIN_EVENT_DATE } from './platformConfig' class NostrService { private pool: SimplePool | null = null private privateKey: string | null = null private publicKey: string | null = null constructor() { if (typeof window !== 'undefined') { this.initializePool() } } private initializePool(): void { this.pool = new SimplePool() } setPrivateKey(privateKey: string): void { this.privateKey = privateKey try { const decoded = nip19.decode(privateKey) if (decoded.type === 'nsec' && typeof decoded.data === 'string') { this.privateKey = decoded.data } } catch { // Assume it's already a hex string } } getPrivateKey(): string | null { return this.privateKey } getPublicKey(): string | null { return this.publicKey } setPublicKey(publicKey: string): void { this.publicKey = publicKey try { const decoded = nip19.decode(publicKey) if (decoded.type === 'npub' && typeof decoded.data === 'string') { this.publicKey = decoded.data } } catch { // Assume it's already a hex string } } async publishEvent(eventTemplate: EventTemplate): Promise { if (!this.privateKey || !this.pool) { throw new Error('Private key not set or pool not initialized') } const unsignedEvent: EventTemplate = { ...eventTemplate, created_at: eventTemplate.created_at ?? Math.floor(Date.now() / 1000), } const secretKey = hexToBytes(this.privateKey) const event = finalizeEvent(unsignedEvent, secretKey) try { // Publish to all active relays (enabled and not marked inactive for this session) // Each event has a unique ID based on content, so publishing to multiple relays // doesn't create duplicates - it's the same event stored redundantly const { relaySessionManager } = await import('./relaySessionManager') const activeRelays = await relaySessionManager.getActiveRelays() if (activeRelays.length === 0) { // Fallback to primary relay if no active relays const relayUrl = await getPrimaryRelay() const pubs = this.pool.publish([relayUrl], event) await Promise.all(pubs) } else { // Publish to all active relays console.warn(`[NostrService] Publishing event ${event.id} to ${activeRelays.length} active relay(s)`) const pubs = this.pool.publish(activeRelays, event) // Track failed relays and mark them inactive for the session const results = await Promise.allSettled(pubs) results.forEach((result, index) => { const relayUrl = activeRelays[index] if (!relayUrl) { return } if (result.status === 'rejected') { const error = result.reason console.error(`[NostrService] Relay ${relayUrl} failed during publish:`, error) relaySessionManager.markRelayFailed(relayUrl) } }) } return event } catch (publishError) { throw new Error(`Publish failed: ${publishError}`) } } private createArticleSubscription(pool: SimplePool, limit: number): ReturnType { // Subscribe to both 'publication' and 'author' type events // Authors are identified by tag type='author' in the tag system // Filter by service='zapwall.fr' to only get notes from this platform // Limit to events published on or after January 6, 2026 const filters = [ { ...buildTagFilter({ type: 'publication', service: PLATFORM_SERVICE, }), since: MIN_EVENT_DATE, limit, }, { ...buildTagFilter({ type: 'author', service: PLATFORM_SERVICE, }), since: MIN_EVENT_DATE, limit, }, ] const relayUrl = getPrimaryRelaySync() return createSubscription(pool, [relayUrl], filters) } subscribeToArticles(callback: (article: Article) => void, limit: number = 100): () => void { if (typeof window === 'undefined') { throw new Error('Cannot subscribe on server side') } if (!this.pool) { this.initializePool() } if (!this.pool) { throw new Error('Pool not initialized') } const sub = this.createArticleSubscription(this.pool, limit) sub.on('event', (event: Event): void => { void (async (): Promise => { try { // Try to parse as regular article first let article = await parseArticleFromEvent(event) // If not a regular article, try to parse as author presentation if (!article) { const presentation = await parsePresentationEvent(event) if (presentation) { article = presentation } } if (article) { callback(article) } } catch (parseError) { console.error('Error parsing article:', parseError) } })() }) return (): void => { sub.unsub() } } getArticleById(eventId: string): Promise
{ if (!this.pool) { throw new Error('Pool not initialized') } const filters = [{ ids: [eventId], kinds: [1] }] return subscribeWithTimeout(this.pool, filters, parseArticleFromEvent, 5000) } getPrivateContent(eventId: string, authorPubkey: string): Promise { if (!this.privateKey || !this.pool || !this.publicKey) { throw new Error('Private key not set or pool not initialized') } return getPrivateContentFromPool(this.pool, eventId, authorPubkey, this.privateKey, this.publicKey) } /** * Get and decrypt article content using decryption key from private message * First retrieves the article event to get the encrypted content, * then retrieves the decryption key from private messages, * and finally decrypts the content */ private async retrieveDecryptionKey(eventId: string, authorPubkey: string): Promise<{ key: string; iv: string } | null> { if (!this.privateKey || !this.pool || !this.publicKey) { return null } return getDecryptionKey(this.pool, eventId, authorPubkey, this.privateKey, this.publicKey) } async getDecryptedArticleContent(eventId: string, authorPubkey: string): Promise { if (!this.privateKey || !this.pool || !this.publicKey) { throw new Error('Private key not set or pool not initialized') } try { const event = await this.getEventById(eventId) if (!event) { console.error('Event not found', { eventId, authorPubkey }) return null } const decryptionKey = await this.retrieveDecryptionKey(eventId, authorPubkey) if (!decryptionKey) { console.warn('Decryption key not found in private messages', { eventId, authorPubkey }) return null } return decryptArticleContentWithKey(event.content, decryptionKey) } catch (decryptError) { console.error('Error decrypting article content', { eventId, authorPubkey, error: decryptError instanceof Error ? decryptError.message : 'Unknown error', }) return null } } /** * Get event by ID (helper method) */ private async getEventById(eventId: string): Promise { if (!this.pool) { throw new Error('Pool not initialized') } const filters = [{ ids: [eventId], kinds: [1] }] return subscribeWithTimeout(this.pool, filters, (event: Event): Event => event, 5000) } getProfile(pubkey: string): Promise { if (!this.pool) { throw new Error('Pool not initialized') } const filters = [ { kinds: [0], authors: [pubkey], limit: 1, }, ] const parseProfile = (event: Event): NostrProfile | null => { try { const profile = JSON.parse(event.content) as NostrProfile return { ...profile, pubkey } } catch (parseProfileError) { console.error('Error parsing profile:', parseProfileError) return null } } return subscribeWithTimeout(this.pool, filters, parseProfile, 5000) } /** * Update Nostr profile (kind 0) with new metadata * Merges new fields with existing profile data */ async updateProfile(updates: Partial): Promise { if (!this.privateKey || !this.publicKey) { throw new Error('Private key and public key must be set to update profile') } // Get existing profile to merge with updates const existingProfile = await this.getProfile(this.publicKey) const currentProfile: NostrProfile = existingProfile ?? { pubkey: this.publicKey, } // Merge updates with existing profile const updatedProfile: NostrProfile = { ...currentProfile, ...updates, pubkey: this.publicKey, // Always use current pubkey } // Create kind 0 event const profileEvent: EventTemplate = { kind: 0, created_at: Math.floor(Date.now() / 1000), tags: [], content: JSON.stringify({ name: updatedProfile.name, about: updatedProfile.about, picture: updatedProfile.picture, nip05: updatedProfile.nip05, lud16: updatedProfile.lud16, lud06: updatedProfile.lud06, }), } await this.publishEvent(profileEvent) } async createZapRequest( targetPubkey: string, targetEventId: string, amount: number, extraTags: string[][] = [] ): Promise { if (!this.privateKey) { throw new Error('Private key not set') } const relayUrl = await getPrimaryRelay() const zapRequest: EventTemplate = { kind: 9734, // Zap request created_at: Math.floor(Date.now() / 1000), tags: [ ['p', targetPubkey], ['e', targetEventId], ['amount', amount.toString()], ['relays', relayUrl], ...extraTags, ], content: '', } const event = await this.publishEvent(zapRequest) if (!event) { throw new Error('Failed to create zap request') } return event } // Check if user has paid for an article by looking for zap receipts checkZapReceipt( targetPubkey: string, targetEventId: string, amount: number, userPubkey?: string ): Promise { if (!this.publicKey || !this.pool) { return Promise.resolve(false) } // Use provided userPubkey or fall back to current public key const checkPubkey = userPubkey ?? this.publicKey return checkZapReceiptHelper(this.pool, targetPubkey, targetEventId, amount, checkPubkey) } /** * Get the pool instance (for use by other services) */ getPool(): SimplePool | null { return this.pool } } export const nostrService = new NostrService()