import { Event, EventTemplate, SimplePool, UnsignedEvent, finalizeEvent, nip04, nip19, verifyEvent, generateSecretKey, getPublicKey } from 'nostr-tools' import { EventEmitter } from 'tseep' import { SignedEvent, Keys } from '../types' export class NostrController extends EventEmitter { private static instance: NostrController private generatedKeys: Keys | undefined private constructor() { super() this.generatedKeys = this.generateKeys() } public static getInstance(): NostrController { if (!NostrController.instance) { NostrController.instance = new NostrController() } return NostrController.instance } /** * Function will publish provided event to the provided relays */ publishEvent = async (event: Event, relays: string[]): Promise => { const simplePool = new SimplePool() const promises = simplePool.publish(relays, event) const results = await Promise.allSettled(promises) const publishedRelays: string[] = [] results.forEach((result, index) => { if (result.status === 'fulfilled') publishedRelays.push(relays[index]) }) if (publishedRelays.length === 0) { const failedPublishes: Array<{ relay: string; error: string }> = [] const fallbackRejectionReason = 'Attempt to publish an event has been rejected with unknown reason.' results.forEach((res, index) => { if (res.status === 'rejected') { failedPublishes.push({ relay: relays[index], error: res.reason ? res.reason.message || fallbackRejectionReason : fallbackRejectionReason }) } }) throw failedPublishes } return publishedRelays } /** * Signs an event with private key (if it is present in local storage) or * with browser extension (if it is present) or * with nSecBunker instance. * @param event - unsigned nostr event. * @returns - a promised that is resolved with signed nostr event. */ signEvent = async ( event: UnsignedEvent | EventTemplate ): Promise => { if (!this.generatedKeys) { throw new Error(`Private & public key pair is not found.`) } const { private: nsec } = this.generatedKeys const privateKey = nip19.decode(nsec).data as Uint8Array const signedEvent = finalizeEvent(event, privateKey) this.verifySignedEvent(signedEvent) return Promise.resolve(signedEvent) } nip04Encrypt = async (receiver: string, content: string): Promise => { if (!this.generatedKeys) { throw new Error(`Private & public key pair is not found.`) } const { private: nsec } = this.generatedKeys const privateKey = nip19.decode(nsec).data as Uint8Array const encrypted = await nip04.encrypt(privateKey, receiver, content) return encrypted } /** * Sends a Direct Message (DM) to a recipient, encrypting the content and handling authentication. * @param fileUrl The URL of the encrypted zip file to be included in the DM. * @param encryptionKey The encryption key used to decrypt the zip file to be included in the DM. * @param pubkey The public key of the recipient. * @param isSigner Boolean indicating whether the recipient is a signer or viewer. * @param setAuthUrl Function to set the authentication URL in the component state. */ sendDM = async (pubkey: string, message: string): Promise => { // Set up timeout promise to handle encryption timeout const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error('Timeout occurred')) }, 60000) // Timeout duration = 60 seconds }) // Encrypt the DM content, with timeout const encrypted = await Promise.race([ this.nip04Encrypt(this.npubToHex(pubkey) as string, message), timeoutPromise ]) // Return if encryption failed if (!encrypted) throw new Error('Message was not encrypted.') // Construct event metadata for the DM const event: EventTemplate = { kind: 4, // DM event type content: encrypted, // Encrypted DM content created_at: Math.floor(Date.now() / 1000), // Current timestamp tags: [['p', this.npubToHex(pubkey) as string]] // Tag with recipient's public key } // Sign the DM event const signedEvent = await this.signEvent(event) // Return if event signing failed if (!signedEvent) throw new Error('Message was not signed.') // These relay will be used to send a DM. Recipient has to read from these relays to receive a DM. const relays = [ 'wss://relay.damus.io/', 'wss://nos.lol/', 'wss://relay.snort.social' ] // Publish the signed DM event to the recipient's read relays return await this.publishEvent(signedEvent, relays) } /** * @param hexKey hex private or public key * @returns whether or not is key valid */ validateHex = (hexKey: string): RegExpMatchArray | null => { return hexKey.match(/^[a-f0-9]{64}$/) } /** * NPUB provided - it will convert NPUB to HEX * HEX provided - it will return HEX * * @param pubKey in NPUB, HEX format * @returns HEX format */ npubToHex = (pubKey: string): string | null => { // If key is NPUB if (pubKey.startsWith('npub1')) { try { return nip19.decode(pubKey).data as string } catch (error) { return null } } // valid hex key if (this.validateHex(pubKey)) return pubKey // Not a valid hex key return null } generateKeys = (): Keys => { const nsec = generateSecretKey() return { private: nip19.nsecEncode(nsec), public: getPublicKey(nsec) } } verifySignedEvent = (event: SignedEvent): void => { const isGood = verifyEvent(event) if (!isGood) { throw new Error( 'Signed event did not pass verification. Check sig, id and pubkey.' ) } } }