Some checks failed
Open PR on Staging / audit_and_check (pull_request) Failing after 9s
1009 lines
30 KiB
TypeScript
1009 lines
30 KiB
TypeScript
import NDK, {
|
|
NDKEvent,
|
|
NDKNip46Signer,
|
|
NDKPrivateKeySigner,
|
|
NDKSubscription,
|
|
NDKUser,
|
|
NostrEvent
|
|
} from '@nostr-dev-kit/ndk'
|
|
import axios from 'axios'
|
|
import {
|
|
Event,
|
|
EventTemplate,
|
|
Filter,
|
|
Relay,
|
|
SimplePool,
|
|
UnsignedEvent,
|
|
finalizeEvent,
|
|
kinds,
|
|
nip04,
|
|
nip19,
|
|
nip44
|
|
} from 'nostr-tools'
|
|
import { toast } from 'react-toastify'
|
|
import { EventEmitter } from 'tseep'
|
|
import {
|
|
setMostPopularRelaysAction,
|
|
setRelayConnectionStatusAction,
|
|
setRelayInfoAction,
|
|
updateNsecbunkerPubkey
|
|
} from '../store/actions'
|
|
import { AuthState, LoginMethods } from '../store/auth/types'
|
|
import store from '../store/store'
|
|
import {
|
|
RelayConnectionState,
|
|
RelayConnectionStatus,
|
|
RelayInfoObject,
|
|
RelayMap,
|
|
RelayReadStats,
|
|
RelayStats,
|
|
SignedEvent
|
|
} from '../types'
|
|
import {
|
|
compareObjects,
|
|
getNsecBunkerDelegatedKey,
|
|
unixNow,
|
|
verifySignedEvent
|
|
} from '../utils'
|
|
import { getDefaultRelayMap } from '../utils/relays.ts'
|
|
|
|
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 = () => {
|
|
// fix: this is not picking up type declaration from src/system/index.d.ts
|
|
if (window.nostr) return window.nostr as any
|
|
|
|
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<NDKNip46Signer> => {
|
|
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
|
|
*
|
|
* @param event - The event to publish.
|
|
* @param relays - An array of relay URLs to publish the event to.
|
|
* @returns A promise that resolves to an array of relays where the event was successfully published.
|
|
*/
|
|
publishEvent = async (event: Event, relays: string[]) => {
|
|
const simplePool = new SimplePool()
|
|
|
|
// Publish the event to all relays
|
|
const promises = simplePool.publish(relays, event)
|
|
|
|
// Use Promise.race to wait for the first successful publish
|
|
const firstSuccessfulPublish = await Promise.race(
|
|
promises.map((promise, index) =>
|
|
promise.then(() => relays[index]).catch(() => null)
|
|
)
|
|
)
|
|
|
|
if (!firstSuccessfulPublish) {
|
|
// If no publish was successful, collect the reasons for failures
|
|
const failedPublishes: unknown[] = []
|
|
const fallbackRejectionReason =
|
|
'Attempt to publish an event has been rejected with unknown reason.'
|
|
|
|
const results = await Promise.allSettled(promises)
|
|
results.forEach((res, index) => {
|
|
if (res.status === 'rejected') {
|
|
failedPublishes.push({
|
|
relay: relays[index],
|
|
error: res.reason
|
|
? res.reason.message || fallbackRejectionReason
|
|
: fallbackRejectionReason
|
|
})
|
|
}
|
|
})
|
|
|
|
throw failedPublishes
|
|
}
|
|
|
|
// Continue publishing to other relays in the background
|
|
promises.forEach((promise, index) => {
|
|
promise.catch((err) => {
|
|
console.log(`Failed to publish to ${relays[index]}`, err)
|
|
})
|
|
})
|
|
|
|
return [firstSuccessfulPublish]
|
|
}
|
|
|
|
/**
|
|
* Asynchronously retrieves an event from a set of relays based on a provided filter.
|
|
* If no relays are specified, it defaults to using connected relays.
|
|
*
|
|
* @param {Filter} filter - The filter criteria to find the event.
|
|
* @param {string[]} [relays] - An optional array of relay URLs to search for the event.
|
|
* @returns {Promise<Event | null>} - Returns a promise that resolves to the found event or null if not found.
|
|
*/
|
|
getEvent = async (
|
|
filter: Filter,
|
|
relays?: string[]
|
|
): Promise<Event | null> => {
|
|
// If no relays are provided or the provided array is empty, use connected relays if available.
|
|
if (!relays || relays.length === 0) {
|
|
relays = this.connectedRelays
|
|
? this.connectedRelays.map((relay) => relay.url)
|
|
: []
|
|
}
|
|
|
|
// If still no relays are available, reject the promise with an error message.
|
|
if (relays.length === 0) {
|
|
return Promise.reject('Provide some relays to find the event')
|
|
}
|
|
|
|
// Create a new instance of SimplePool to handle the relay connections and event retrieval.
|
|
const pool = new SimplePool()
|
|
|
|
// Attempt to retrieve the event from the specified relays using the filter criteria.
|
|
const event = await pool.get(relays, filter).catch((err) => {
|
|
// Log any errors that occur during the event retrieval process.
|
|
console.log('An error occurred in finding the event', err)
|
|
// Show an error toast notification to the user.
|
|
toast.error('An error occurred in finding the event')
|
|
// Return null if an error occurs, indicating that no event was found.
|
|
return null
|
|
})
|
|
|
|
// Return the found event, or null if an error occurred.
|
|
return event
|
|
}
|
|
|
|
/**
|
|
* Encrypts the given content for the specified receiver using NIP-44 encryption.
|
|
*
|
|
* @param receiver The public key of the receiver.
|
|
* @param content The content to be encrypted.
|
|
* @returns The encrypted content as a string.
|
|
* @throws Error if the nostr extension does not support NIP-44, if the private key pair is not found, or if the login method is unsupported.
|
|
*/
|
|
nip44Encrypt = async (receiver: string, content: string) => {
|
|
// Retrieve the current login method from the application's redux state.
|
|
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
|
|
|
// Handle encryption when the login method is via an extension.
|
|
if (loginMethod === LoginMethods.extension) {
|
|
const nostr = this.getNostrObject()
|
|
|
|
// Check if the nostr object supports NIP-44 encryption.
|
|
if (!nostr.nip44) {
|
|
throw new Error(
|
|
`Your nostr extension does not support nip44 encryption & decryption`
|
|
)
|
|
}
|
|
|
|
// Encrypt the content using NIP-44 provided by the nostr extension.
|
|
const encrypted = await nostr.nip44.encrypt(receiver, content)
|
|
return encrypted as string
|
|
}
|
|
|
|
// Handle encryption when the login method is via a private key.
|
|
if (loginMethod === LoginMethods.privateKey) {
|
|
const keys = (store.getState().auth as AuthState).keyPair
|
|
|
|
// Check if the private and public key pair is available.
|
|
if (!keys) {
|
|
throw new Error(
|
|
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
|
|
)
|
|
}
|
|
|
|
// Decode the private key.
|
|
const { private: nsec } = keys
|
|
const privateKey = nip19.decode(nsec).data as Uint8Array
|
|
|
|
// Generate the conversation key using NIP-44 utilities.
|
|
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
|
|
privateKey,
|
|
receiver
|
|
)
|
|
|
|
// Encrypt the content using the generated conversation key.
|
|
const encrypted = nip44.v2.encrypt(content, nip44ConversationKey)
|
|
|
|
return encrypted
|
|
}
|
|
|
|
// Throw an error if the login method is nsecBunker (not supported).
|
|
if (loginMethod === LoginMethods.nsecBunker) {
|
|
throw new Error(
|
|
`nip44 encryption is not yet supported for login method '${LoginMethods.nsecBunker}'`
|
|
)
|
|
}
|
|
|
|
// Throw an error if the login method is undefined or unsupported.
|
|
throw new Error('Login method is undefined')
|
|
}
|
|
|
|
/**
|
|
* Decrypts the given content from the specified sender using NIP-44 decryption.
|
|
*
|
|
* @param sender The public key of the sender.
|
|
* @param content The encrypted content to be decrypted.
|
|
* @returns The decrypted content as a string.
|
|
* @throws Error if the nostr extension does not support NIP-44, if the private key pair is not found, or if the login method is unsupported.
|
|
*/
|
|
nip44Decrypt = async (sender: string, content: string) => {
|
|
// Retrieve the current login method from the application's redux state.
|
|
const loginMethod = (store.getState().auth as AuthState).loginMethod
|
|
|
|
// Handle decryption when the login method is via an extension.
|
|
if (loginMethod === LoginMethods.extension) {
|
|
const nostr = this.getNostrObject()
|
|
|
|
// Check if the nostr object supports NIP-44 decryption.
|
|
if (!nostr.nip44) {
|
|
throw new Error(
|
|
`Your nostr extension does not support nip44 encryption & decryption`
|
|
)
|
|
}
|
|
|
|
// Decrypt the content using NIP-44 provided by the nostr extension.
|
|
const decrypted = await nostr.nip44.decrypt(sender, content)
|
|
return decrypted as string
|
|
}
|
|
|
|
// Handle decryption when the login method is via a private key.
|
|
if (loginMethod === LoginMethods.privateKey) {
|
|
const keys = (store.getState().auth as AuthState).keyPair
|
|
|
|
// Check if the private and public key pair is available.
|
|
if (!keys) {
|
|
throw new Error(
|
|
`Login method is ${LoginMethods.privateKey} but private & public key pair is not found.`
|
|
)
|
|
}
|
|
|
|
// Decode the private key.
|
|
const { private: nsec } = keys
|
|
const privateKey = nip19.decode(nsec).data as Uint8Array
|
|
|
|
// Generate the conversation key using NIP-44 utilities.
|
|
const nip44ConversationKey = nip44.v2.utils.getConversationKey(
|
|
privateKey,
|
|
sender
|
|
)
|
|
|
|
// Decrypt the content using the generated conversation key.
|
|
const decrypted = nip44.v2.decrypt(content, nip44ConversationKey)
|
|
|
|
return decrypted
|
|
}
|
|
|
|
// Throw an error if the login method is nsecBunker (not supported).
|
|
if (loginMethod === LoginMethods.nsecBunker) {
|
|
throw new Error(
|
|
`nip44 decryption is not yet supported for login method '${LoginMethods.nsecBunker}'`
|
|
)
|
|
}
|
|
|
|
// Throw an error if the login method is undefined or unsupported.
|
|
throw new Error('Login method is undefined')
|
|
}
|
|
|
|
/**
|
|
* 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<SignedEvent> => {
|
|
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: unknown) => {
|
|
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): Promise<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): Promise<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<string> => {
|
|
const nostr = this.getNostrObject()
|
|
const pubKey = await nostr.getPublicKey().catch((err: unknown) => {
|
|
if (err instanceof Error) {
|
|
return Promise.reject(err.message)
|
|
} else {
|
|
return Promise.reject(JSON.stringify(err))
|
|
}
|
|
})
|
|
|
|
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.resolve({ map: getDefaultRelayMap() })
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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<string> => {
|
|
const timestamp = unixNow()
|
|
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<string[]> => {
|
|
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<RelayStats>(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: unixNow(),
|
|
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<string> => {
|
|
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))
|
|
}
|
|
}
|
|
}
|
|
}
|