import NDK, { NDKEvent, NDKNip46Signer, NDKPrivateKeySigner, NDKUser, NostrEvent, NDKSubscription } from '@nostr-dev-kit/ndk' import { Event, EventTemplate, SimplePool, UnsignedEvent, Filter, Relay, finalizeEvent, nip04, nip19, kinds } from 'nostr-tools' import { EventEmitter } from 'tseep' import { updateNsecbunkerPubkey, setMostPopularRelaysAction, setRelayInfoAction, setRelayConnectionStatusAction } from '../store/actions' import { AuthState, LoginMethods } from '../store/auth/types' import store from '../store/store' import { SignedEvent, RelayMap, RelayStats, RelayReadStats, RelayInfoObject, RelayConnectionStatus, RelayConnectionState } from '../types' import { compareObjects, getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' import axios from 'axios' export class NostrController extends EventEmitter { private static instance: NostrController private bunkerNDK: NDK | undefined private remoteSigner: NDKNip46Signer | undefined private connectedRelays: Relay[] | undefined private constructor() { super() } private getNostrObject = () => { if (window.nostr) return window.nostr throw new Error( `window.nostr object not present. Make sure you have an nostr extension installed/working properly.` ) } public nsecBunkerInit = async (relays: string[]) => { // Don't reinstantiate bunker NDK if exists with same relays if ( this.bunkerNDK && this.bunkerNDK.explicitRelayUrls?.length === relays.length && this.bunkerNDK.explicitRelayUrls?.every((relay) => relays.includes(relay)) ) return this.bunkerNDK = new NDK({ explicitRelayUrls: relays }) try { await this.bunkerNDK .connect(2000) .then(() => { console.log( `Successfully connected to the nsecBunker relays: ${relays.join( ',' )}` ) }) .catch((err) => { console.error( `Error connecting to the nsecBunker relays: ${relays.join( ',' )} ${err}` ) }) } catch (err) { console.error(err) } } /** * Creates nSecBunker signer instance for the given npub * Or if npub omitted it will return existing signer * If neither, error will be thrown * @param npub nPub / public key in hex format * @returns nsecBunker Signer instance */ public createNsecBunkerSigner = async ( npub: string | undefined ): Promise => { const nsecBunkerDelegatedKey = getNsecBunkerDelegatedKey() return new Promise((resolve, reject) => { if (!nsecBunkerDelegatedKey) { reject('nsecBunker delegated key is not found in the browser.') return } const localSigner = new NDKPrivateKeySigner(nsecBunkerDelegatedKey) if (!npub) { if (this.remoteSigner) resolve(this.remoteSigner) const npubFromStorage = (store.getState().auth as AuthState) .nsecBunkerPubkey if (npubFromStorage) { npub = npubFromStorage } else { reject( 'No signer instance present, no npub provided by user or found in the browser.' ) return } } else { store.dispatch(updateNsecbunkerPubkey(npub)) } // Pubkey of a key pair stored in nsecbunker that will be used to sign event with const appPubkeyOrToken = npub.includes('npub') ? npub : nip19.npubEncode(npub) /** * When creating and NDK instance we create new connection to the relay * To prevent too much connections and hitting rate limits, if npub against which we sign * we will reuse existing instance. Otherwise we will create new NDK and signer instance. */ if (!this.remoteSigner || this.remoteSigner?.remotePubkey !== npub) { this.remoteSigner = new NDKNip46Signer( this.bunkerNDK!, appPubkeyOrToken, localSigner ) } /** * when nsecbunker-delegated-key is regenerated we have to reinitialize the remote signer */ if (this.remoteSigner.localSigner !== localSigner) { this.remoteSigner = new NDKNip46Signer( this.bunkerNDK!, appPubkeyOrToken, localSigner ) } resolve(this.remoteSigner) }) } /** * Signs the nostr event and returns the sig and id or full raw nostr event * @param npub stored in nsecBunker to sign with * @param event to be signed * @param returnFullEvent whether to return full raw nostr event or just SIG and ID values */ public signWithNsecBunker = async ( npub: string | undefined, event: NostrEvent, returnFullEvent = true ): Promise<{ id: string; sig: string } | NostrEvent> => { return new Promise((resolve, reject) => { this.createNsecBunkerSigner(npub) .then(async (signer) => { const ndkEvent = new NDKEvent(undefined, event) const timeout = setTimeout(() => { reject('Timeout occurred while waiting for event signing') }, 60000) // 60000 ms (1 min) = 1000 * 60 await ndkEvent.sign(signer).catch((err) => { clearTimeout(timeout) reject(err) return }) clearTimeout(timeout) if (returnFullEvent) { resolve(ndkEvent.rawEvent()) } else { resolve({ id: ndkEvent.id, sig: ndkEvent.sig! }) } }) .catch((err) => { reject(err) }) }) } 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[]) => { const simplePool = new SimplePool() const promises = simplePool.publish(relays, event) const results = await Promise.allSettled(promises) const publishedRelays: string[] = [] console.log('results of publish event :>> ', results) results.forEach((result, index) => { if (result.status === 'fulfilled') { publishedRelays.push(relays[index]) } }) if (publishedRelays.length === 0) { const failedPublishes: any[] = [] 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 => { const loginMethod = (store.getState().auth as AuthState).loginMethod if (!loginMethod) { return Promise.reject('No login method found in the browser storage') } if (loginMethod === LoginMethods.nsecBunker) { // Check if nsecBunker is available if (!this.bunkerNDK) { return Promise.reject( `Login method is ${loginMethod} but bunkerNDK is not created` ) } if (!this.remoteSigner) { return Promise.reject( `Login method is ${loginMethod} but bunkerNDK is not created` ) } const signedEvent = await this.signWithNsecBunker( '', event as NostrEvent ).catch((err) => { throw err }) return Promise.resolve(signedEvent as SignedEvent) } else if (loginMethod === LoginMethods.privateKey) { const keys = (store.getState().auth as AuthState).keyPair if (!keys) { return Promise.reject( `Login method is ${loginMethod}, but keys are not found` ) } const { private: nsec } = keys const privateKey = nip19.decode(nsec).data as Uint8Array const signedEvent = finalizeEvent(event, privateKey) verifySignedEvent(signedEvent) return Promise.resolve(signedEvent) } else if (loginMethod === LoginMethods.extension) { const nostr = this.getNostrObject() return (await nostr.signEvent(event as NostrEvent).catch((err: any) => { console.log('Error while signing event: ', err) throw err })) as Event } else { return Promise.reject( `We could not sign the event, none of the signing methods are available` ) } } nip04Encrypt = async (receiver: string, content: string) => { const loginMethod = (store.getState().auth as AuthState).loginMethod if (loginMethod === LoginMethods.extension) { const nostr = this.getNostrObject() if (!nostr.nip04) { throw new Error( `Your nostr extension does not support nip04 encryption & decryption` ) } const encrypted = await nostr.nip04.encrypt(receiver, content) return encrypted } if (loginMethod === LoginMethods.privateKey) { const keys = (store.getState().auth as AuthState).keyPair if (!keys) { throw new Error( `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.` ) } const { private: nsec } = keys const privateKey = nip19.decode(nsec).data as Uint8Array const encrypted = await nip04.encrypt(privateKey, receiver, content) return encrypted } if (loginMethod === LoginMethods.nsecBunker) { const user = new NDKUser({ pubkey: receiver }) this.remoteSigner?.on('authUrl', (authUrl) => { this.emit('nsecbunker-auth', authUrl) }) if (!this.remoteSigner) throw new Error('Remote signer is undefined.') const encrypted = await this.remoteSigner.encrypt(user, content) return encrypted } throw new Error('Login method is undefined') } /** * Decrypts a given content based on the current login method. * * @param sender - The sender's public key. * @param content - The encrypted content to decrypt. * @returns A promise that resolves to the decrypted content. */ nip04Decrypt = async (sender: string, content: string) => { const loginMethod = (store.getState().auth as AuthState).loginMethod if (loginMethod === LoginMethods.extension) { const nostr = this.getNostrObject() if (!nostr.nip04) { throw new Error( `Your nostr extension does not support nip04 encryption & decryption` ) } const decrypted = await nostr.nip04.decrypt(sender, content) return decrypted } if (loginMethod === LoginMethods.privateKey) { const keys = (store.getState().auth as AuthState).keyPair if (!keys) { throw new Error( `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.` ) } const { private: nsec } = keys const privateKey = nip19.decode(nsec).data as Uint8Array const decrypted = await nip04.decrypt(privateKey, sender, content) return decrypted } if (loginMethod === LoginMethods.nsecBunker) { const user = new NDKUser({ pubkey: sender }) this.remoteSigner?.on('authUrl', (authUrl) => { this.emit('nsecbunker-auth', authUrl) }) if (!this.remoteSigner) throw new Error('Remote signer is undefined.') const decrypted = await this.remoteSigner.decrypt(user, content) return decrypted } throw new Error('Login method is undefined') } /** * Function will capture the public key from the nostr extension or if no extension present * function wil capture the public key from the local storage */ capturePublicKey = async (): Promise => { const nostr = this.getNostrObject() const pubKey = await nostr.getPublicKey().catch((err: any) => { return Promise.reject(err.message) }) if (!pubKey) { return Promise.reject('Error getting public key, user canceled') } return Promise.resolve(pubKey) } /** * Generates NDK Private Signer * @returns nSecBunker delegated key */ generateDelegatedKey = (): string => { return NDKPrivateKeySigner.generate().privateKey! } /** * Provides relay map. * @param npub - user's npub * @returns - promise that resolves into relay map and a timestamp when it has been updated. */ getRelayMap = async ( npub: string ): Promise<{ map: RelayMap; mapUpdated: number }> => { const mostPopularRelays = await this.getMostPopularRelays() const pool = new SimplePool() // More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md const eventFilter: Filter = { kinds: [kinds.RelayList], authors: [npub] } const event = await pool .get(mostPopularRelays, eventFilter) .catch((err) => { return Promise.reject(err) }) if (event) { // Handle founded 10002 event const relaysMap: RelayMap = {} // 'r' stands for 'relay' const relayTags = event.tags.filter((tag) => tag[0] === 'r') relayTags.forEach((tag) => { const uri = tag[1] const relayType = tag[2] // if 3rd element of relay tag is undefined, relay is WRITE and READ relaysMap[uri] = { write: relayType ? relayType === 'write' : true, read: relayType ? relayType === 'read' : true } }) this.getRelayInfo(Object.keys(relaysMap)) this.connectToRelays(Object.keys(relaysMap)) return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at }) } else { return Promise.reject('User relays were not found.') } } /** * Publishes relay map. * @param relayMap - relay map. * @param npub - user's npub. * @param extraRelaysToPublish - optional relays to publish relay map. * @returns - promise that resolves into a string representing publishing result. */ publishRelayMap = async ( relayMap: RelayMap, npub: string, extraRelaysToPublish?: string[] ): Promise => { const timestamp = Math.floor(Date.now() / 1000) const relayURIs = Object.keys(relayMap) // More info about this kind of event available https://github.com/nostr-protocol/nips/blob/master/65.md const tags: string[][] = relayURIs.map((relayURI) => [ 'r', relayURI, relayMap[relayURI].read && relayMap[relayURI].write ? '' : relayMap[relayURI].write ? 'write' : 'read' ].filter((value) => value !== '') ) const newRelayMapEvent: UnsignedEvent = { kind: kinds.RelayList, tags, content: '', pubkey: npub, created_at: timestamp } const signedEvent = await this.signEvent(newRelayMapEvent) let relaysToPublish = relayURIs // Add extra relays if provided if (extraRelaysToPublish) { relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish] } // If relay map is empty, use most popular relay URIs if (!relaysToPublish.length) { relaysToPublish = await this.getMostPopularRelays() } const publishResult = await this.publishEvent(signedEvent, relaysToPublish) if (publishResult && publishResult.length) { return Promise.resolve( `Relay Map published on: ${publishResult.join('\n')}` ) } return Promise.reject('Publishing updated relay map was unsuccessful.') } /** * Provides most popular relays. * @param numberOfTopRelays - number representing how many most popular relays to provide * @returns - promise that resolves into an array of most popular relays */ getMostPopularRelays = async ( numberOfTopRelays: number = 30 ): Promise => { const mostPopularRelaysState = store.getState().relays?.mostPopular // return most popular relays from app state if present if (mostPopularRelaysState) return mostPopularRelaysState // relays in env const { VITE_MOST_POPULAR_RELAYS } = import.meta.env const hardcodedPopularRelays = (VITE_MOST_POPULAR_RELAYS || '').split(' ') const url = `https://stats.nostr.band/stats_api?method=stats` const response = await axios.get(url).catch(() => undefined) if (!response) { return hardcodedPopularRelays //return hardcoded relay list } const data = response.data if (!data) { return hardcodedPopularRelays //return hardcoded relay list } const apiTopRelays = data.relay_stats.user_picks.read_relays .slice(0, numberOfTopRelays) .map((relay: RelayReadStats) => relay.d) if (!apiTopRelays.length) { return Promise.reject(`Couldn't fetch popular relays.`) } if (store.getState().auth?.loggedIn) { store.dispatch(setMostPopularRelaysAction(apiTopRelays)) } return apiTopRelays } /** * Sets information about relays into relays.info app state. * @param relayURIs - relay URIs to get information about */ getRelayInfo = async (relayURIs: string[]) => { // initialize job request const jobEventTemplate: EventTemplate = { content: '', created_at: Math.round(Date.now() / 1000), kind: 68001, tags: [ ['i', `${JSON.stringify(relayURIs)}`], ['j', 'relay-info'] ] } // sign job request event const jobSignedEvent = await this.signEvent(jobEventTemplate) const relays = [ 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://relayable.org' ] // publish job request await this.publishEvent(jobSignedEvent, relays) console.log('jobSignedEvent :>> ', jobSignedEvent) const subscribeWithTimeout = ( subscription: NDKSubscription, timeoutMs: number ): Promise => { return new Promise((resolve, reject) => { const eventHandler = (event: NDKEvent) => { subscription.stop() resolve(event.content) } subscription.on('event', eventHandler) // Set up a timeout to stop the subscription after a specified time const timeout = setTimeout(() => { subscription.stop() // Stop the subscription reject(new Error('Subscription timed out')) // Reject the promise with a timeout error }, timeoutMs) // Handle subscription close event subscription.on('close', () => clearTimeout(timeout)) }) } const dvmNDK = new NDK({ explicitRelayUrls: relays }) await dvmNDK.connect(2000) // filter for getting DVM job's result const sub = dvmNDK.subscribe({ kinds: [68002 as number], '#e': [jobSignedEvent.id], '#p': [jobSignedEvent.pubkey] }) // asynchronously get block number from dvm job with 20 seconds timeout const dvmJobResult = await subscribeWithTimeout(sub, 20000) if (!dvmJobResult) { return Promise.reject(`Relay(s) information wasn't received`) } let relaysInfo: RelayInfoObject try { relaysInfo = JSON.parse(dvmJobResult) } catch (error) { return Promise.reject(`Invalid relay(s) information.`) } if ( relaysInfo && !compareObjects(store.getState().relays?.info, relaysInfo) ) { store.dispatch(setRelayInfoAction(relaysInfo)) } } /** * Establishes connection to relays. * @param relayURIs - an array of relay URIs * @returns - promise that resolves into an array of connections */ connectToRelays = async (relayURIs: string[]) => { // Copy of relay connection status const relayConnectionsStatus: RelayConnectionStatus = JSON.parse( JSON.stringify(store.getState().relays?.connectionStatus || {}) ) const connectedRelayURLs = this.connectedRelays ? this.connectedRelays.map((relay) => relay.url) : [] // Check if connections already established if (compareObjects(connectedRelayURLs, relayURIs)) { return } const connections = relayURIs .filter((relayURI) => !connectedRelayURLs.includes(relayURI)) .map((relayURI) => Relay.connect(relayURI) .then((relay) => { // put connection status into relayConnectionsStatus object relayConnectionsStatus[relayURI] = relay.connected ? RelayConnectionState.Connected : RelayConnectionState.NotConnected return relay }) .catch(() => { relayConnectionsStatus[relayURI] = RelayConnectionState.NotConnected }) ) const connected = await Promise.all(connections) // put connected relays into connectedRelays private property, so it can be closed later this.connectedRelays = connected.filter( (relay) => relay instanceof Relay && relay.connected ) as Relay[] if (Object.keys(relayConnectionsStatus)) { if ( !compareObjects( store.getState().relays?.connectionStatus, relayConnectionsStatus ) ) { store.dispatch(setRelayConnectionStatusAction(relayConnectionsStatus)) } } return Promise.resolve(relayConnectionsStatus) } /** * Disconnects from relays. * @param relayURIs - array of relay URIs to disconnect from */ disconnectFromRelays = async (relayURIs: string[]) => { const connectedRelayURLs = this.connectedRelays ? this.connectedRelays.map((relay) => relay.url) : [] relayURIs .filter((relayURI) => connectedRelayURLs.includes(relayURI)) .forEach((relayURI) => { if (this.connectedRelays) { const relay = this.connectedRelays.find( (relay) => relay.url === relayURI ) if (relay) { // close relay connection relay.close() // remove relay from connectedRelays property this.connectedRelays = this.connectedRelays.filter( (relay) => relay.url !== relayURI ) } } }) if (store.getState().relays?.connectionStatus) { const connectionStatus = JSON.parse( JSON.stringify(store.getState().relays?.connectionStatus) ) relayURIs.forEach((relay) => { delete connectionStatus[relay] }) if ( !compareObjects( store.getState().relays?.connectionStatus, connectionStatus ) ) { // Update app state store.dispatch(setRelayConnectionStatusAction(connectionStatus)) } } } }