feat(Relays): added logic to manage relays #63

Closed
y wants to merge 8 commits from relays-management into main
2 changed files with 304 additions and 41 deletions
Showing only changes of commit 5d17ae8d4a - Show all commits

View File

@ -74,24 +74,15 @@ export class AuthController {
}) })
) )
const relaysState = store.getState().relays
if (relaysState) {
// Relays state is defined and there is no need to await for the latest relay map
this.nostrController.getRelayMap(pubkey).then((relayMap) => {
if (!compareObjects(relaysState?.map, relayMap)) {
store.dispatch(setRelayMapAction(relayMap.map))
}
})
} else {
// Relays state is not defined, await for the latest relay map
const relayMap = await this.nostrController.getRelayMap(pubkey) const relayMap = await this.nostrController.getRelayMap(pubkey)
if (Object.keys(relayMap).length < 1) { if (Object.keys(relayMap).length < 1) {
// Navigate user to relays page // Navigate user to relays page if relay map is empty
return Promise.resolve(appPrivateRoutes.relays) return Promise.resolve(appPrivateRoutes.relays)
} }
if (store.getState().auth?.loggedIn) {
if (!compareObjects(store.getState().relays?.map, relayMap.map))
store.dispatch(setRelayMapAction(relayMap.map)) store.dispatch(setRelayMapAction(relayMap.map))
} }

View File

@ -3,7 +3,8 @@ import NDK, {
NDKNip46Signer, NDKNip46Signer,
NDKPrivateKeySigner, NDKPrivateKeySigner,
NDKUser, NDKUser,
NostrEvent NostrEvent,
NDKSubscription
} from '@nostr-dev-kit/ndk' } from '@nostr-dev-kit/ndk'
import { import {
Event, Event,
@ -11,25 +12,45 @@ import {
SimplePool, SimplePool,
UnsignedEvent, UnsignedEvent,
Filter, Filter,
Relay,
finalizeEvent, finalizeEvent,
nip04, nip04,
nip19, nip19,
kinds kinds
} from 'nostr-tools' } from 'nostr-tools'
import { EventEmitter } from 'tseep' import { EventEmitter } from 'tseep'
import { updateNsecbunkerPubkey } from '../store/actions' import {
updateNsecbunkerPubkey,
setMostPopularRelaysAction,
setRelayInfoAction,
setRelayConnectionStatusAction
} from '../store/actions'
import { AuthState, LoginMethods } from '../store/auth/types' import { AuthState, LoginMethods } from '../store/auth/types'
import store from '../store/store' import store from '../store/store'
import { SignedEvent, RelayMap } from '../types' import {
import { getNsecBunkerDelegatedKey, verifySignedEvent } from '../utils' SignedEvent,
RelayMap,
RelayStats,
ReadRelay,
RelayInfoObject,
RelayConnectionStatus,
RelayConnectionState
} from '../types'
import {
compareObjects,
getNsecBunkerDelegatedKey,
verifySignedEvent
} from '../utils'
import axios from 'axios'
export class NostrController extends EventEmitter { export class NostrController extends EventEmitter {
private static instance: NostrController private static instance: NostrController
private specialMetadataRelay = 'wss://purplepag.es'
private bunkerNDK: NDK | undefined private bunkerNDK: NDK | undefined
private remoteSigner: NDKNip46Signer | undefined private remoteSigner: NDKNip46Signer | undefined
private connectedRelays: Relay[] | undefined
private constructor() { private constructor() {
super() super()
} }
@ -390,12 +411,7 @@ export class NostrController extends EventEmitter {
getRelayMap = async ( getRelayMap = async (
npub: string npub: string
): Promise<{ map: RelayMap; mapUpdated: number }> => { ): Promise<{ map: RelayMap; mapUpdated: number }> => {
const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS const mostPopularRelays = await this.getMostPopularRelays()
const hardcodedPopularRelays = (mostPopularRelays || '').split(' ')
const popularRelayURIs = [
this.specialMetadataRelay,
...hardcodedPopularRelays
]
const pool = new SimplePool() const pool = new SimplePool()
@ -405,7 +421,9 @@ export class NostrController extends EventEmitter {
authors: [npub] authors: [npub]
} }
const event = await pool.get(popularRelayURIs, eventFilter).catch((err) => { const event = await pool
.get(mostPopularRelays, eventFilter)
.catch((err) => {
return Promise.reject(err) return Promise.reject(err)
}) })
@ -427,6 +445,10 @@ export class NostrController extends EventEmitter {
} }
}) })
this.getRelayInfo(Object.keys(relaysMap))
this.connectToRelays(Object.keys(relaysMap))
return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at }) return Promise.resolve({ map: relaysMap, mapUpdated: event.created_at })
} else { } else {
return Promise.reject('User relays were not found.') return Promise.reject('User relays were not found.')
@ -437,11 +459,13 @@ export class NostrController extends EventEmitter {
* Publishes relay map. * Publishes relay map.
* @param relayMap - relay map. * @param relayMap - relay map.
* @param npub - user's npub. * @param npub - user's npub.
* @param extraRelaysToPublish - optional relays to publish relay map.
* @returns - promise that resolves into a string representing publishing result. * @returns - promise that resolves into a string representing publishing result.
*/ */
publishRelayMap = async ( publishRelayMap = async (
relayMap: RelayMap, relayMap: RelayMap,
npub: string npub: string,
extraRelaysToPublish?: string[]
): Promise<string> => { ): Promise<string> => {
const timestamp = Math.floor(Date.now() / 1000) const timestamp = Math.floor(Date.now() / 1000)
const relayURIs = Object.keys(relayMap) const relayURIs = Object.keys(relayMap)
@ -471,18 +495,266 @@ export class NostrController extends EventEmitter {
let relaysToPublish = relayURIs let relaysToPublish = relayURIs
// Add extra relays if provided
if (extraRelaysToPublish) {
relaysToPublish = [...relaysToPublish, ...extraRelaysToPublish]
}
// If relay map is empty, use most popular relay URIs // If relay map is empty, use most popular relay URIs
if (!relaysToPublish.length) { if (!relaysToPublish.length) {
const mostPopularRelays = import.meta.env.VITE_MOST_POPULAR_RELAYS relaysToPublish = await this.getMostPopularRelays()
const hardcodedPopularRelays = (mostPopularRelays || '').split(' ')
relaysToPublish = [this.specialMetadataRelay, ...hardcodedPopularRelays]
} }
await this.publishEvent(signedEvent, relaysToPublish) const publishResult = await this.publishEvent(signedEvent, relaysToPublish)
if (publishResult && publishResult.length) {
return Promise.resolve( return Promise.resolve(
`Relay Map published on: ${relaysToPublish.join('\n')}` `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: ReadRelay) => 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<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))
}
}
}
} }