import NDK, { NDKEvent, NDKNip46Signer, NDKPrivateKeySigner, NDKUser, NostrEvent } from '@nostr-dev-kit/ndk' import { Event, EventTemplate, SimplePool, UnsignedEvent, finalizeEvent, nip04, nip19 } from 'nostr-tools' import { EventEmitter } from 'tseep' import { updateNsecbunkerPubkey } from '../store/actions' import { AuthState, LoginMethods } from '../store/auth/types' import store from '../store/store' import { SignedEvent } from '../types' import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' export class NostrController extends EventEmitter { private static instance: NostrController private bunkerNDK: NDK | undefined private remoteSigner: NDKNip46Signer | 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[] = [] results.forEach((res, index) => { if (res.status === 'rejected') { failedPublishes.push({ relay: relays[index], error: res.reason.message }) } }) 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 keyPair = (store.getState().auth as AuthState).keyPair if (!keyPair) { throw new Error( `Login method is ${LoginMethods.privateKey} but private & public key pair is not found.` ) } const encrypted = await nip04.encrypt(keyPair.private, 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') } /** * 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! } }