import { Filter, SimplePool, VerifiedEvent, kinds, validateEvent, verifyEvent, Event, EventTemplate, nip19 } from 'nostr-tools' import { NostrJoiningBlock, ProfileMetadata, RelaySet } from '../types' import { NostrController } from '.' import { toast } from 'react-toastify' import { queryNip05 } from '../utils' import NDK, { NDKEvent, NDKSubscription } from '@nostr-dev-kit/ndk' import { EventEmitter } from 'tseep' import { localCache } from '../services' export class MetadataController extends EventEmitter { private nostrController: NostrController private specialMetadataRelay = 'wss://purplepag.es' constructor() { super() this.nostrController = NostrController.getInstance() } /** * Asynchronously checks for more recent metadata events authored by a specific key. * If a more recent metadata event is found, it is handled and returned. * If no more recent event is found, the current event is returned. * @param hexKey The hexadecimal key of the author to filter metadata events. * @param currentEvent The current metadata event, if any, to compare with newer events. * @returns A promise resolving to the most recent metadata event found, or null if none is found. */ private async checkForMoreRecentMetadata( hexKey: string, currentEvent: Event | null ): Promise { // Define the event filter to only include metadata events authored by the given key const eventFilter: Filter = { kinds: [kinds.Metadata], // Only metadata events authors: [hexKey] // Authored by the specified key } const pool = new SimplePool() // Try to get the metadata event from a special relay (wss://purplepag.es) const metadataEvent = await pool .get([this.specialMetadataRelay], eventFilter) .catch((err) => { console.error(err) // Log any errors return null // Return null if an error occurs }) // If a valid metadata event is found from the special relay if ( metadataEvent && validateEvent(metadataEvent) && // Validate the event verifyEvent(metadataEvent) // Verify the event's authenticity ) { // If there's no current event or the new metadata event is more recent if (!currentEvent || metadataEvent.created_at > currentEvent.created_at) { // Handle the new metadata event this.handleNewMetadataEvent(metadataEvent) return metadataEvent } } // If no valid metadata event is found from the special relay, get the most popular relays const mostPopularRelays = await this.nostrController.getMostPopularRelays() // Query the most popular relays for metadata events const events = await pool .querySync(mostPopularRelays, eventFilter) .catch((err) => { console.error(err) // Log any errors return null // Return null if an error occurs }) // If events are found from the popular relays if (events && events.length) { events.sort((a, b) => b.created_at - a.created_at) // Sort events by creation date (descending) // Iterate through the events for (const event of events) { // If the event is valid, authentic, and more recent than the current event if ( validateEvent(event) && verifyEvent(event) && (!currentEvent || event.created_at > currentEvent.created_at) ) { // Handle the new metadata event this.handleNewMetadataEvent(event) return event } } } return currentEvent // Return the current event if no newer event is found } /** * Handle new metadata events and emit them to subscribers */ private async handleNewMetadataEvent(event: VerifiedEvent) { // update the event in local cache localCache.addUserMetadata(event) // Emit the event to subscribers. this.emit(event.pubkey, event.kind, event) } /** * Finds metadata for a given hexadecimal key. * * @param hexKey - The hexadecimal key to search for metadata. * @returns A promise that resolves to the metadata event. */ public findMetadata = async (hexKey: string): Promise => { // Attempt to retrieve the metadata event from the local cache const cachedMetadataEvent = await localCache.getUserMetadata(hexKey) // If cached metadata is found, check its validity if (cachedMetadataEvent) { const oneDayInMS = 24 * 60 * 60 * 1000 // Number of milliseconds in one day // Check if the cached metadata is older than one day if (Date.now() - cachedMetadataEvent.cachedAt > oneDayInMS) { // If older than one day, find the metadata from relays in background this.checkForMoreRecentMetadata(hexKey, cachedMetadataEvent.event) } // Return the cached metadata event return cachedMetadataEvent.event } // If no cached metadata is found, retrieve it from relays return this.checkForMoreRecentMetadata(hexKey, null) } public findRelayListMetadata = async (hexKey: string) => { const eventFilter: Filter = { kinds: [kinds.RelayList], authors: [hexKey] } const pool = new SimplePool() let relayEvent = await pool .get([this.specialMetadataRelay], eventFilter) .catch((err) => { console.error(err) return null }) if (!relayEvent) { const mostPopularRelays = await this.nostrController.getMostPopularRelays() relayEvent = await pool .get(mostPopularRelays, eventFilter) .catch((err) => { console.error(err) return null }) } if (relayEvent) { const relaySet: RelaySet = { read: [], write: [] } // a list of r tags with relay URIs and a read or write marker. const relayTags = relayEvent.tags.filter((tag) => tag[0] === 'r') // Relays marked as read / write are called READ / WRITE relays, respectively relayTags.forEach((tag) => { if (tag.length >= 3) { const marker = tag[2] if (marker === 'read') { relaySet.read.push(tag[1]) } else if (marker === 'write') { relaySet.write.push(tag[1]) } } // If the marker is omitted, the relay is used for both purposes if (tag.length === 2) { relaySet.read.push(tag[1]) relaySet.write.push(tag[1]) } }) return relaySet } throw new Error('No relay list metadata found.') } public extractProfileMetadataContent = (event: Event) => { try { if (!event.content) return {} return JSON.parse(event.content) as ProfileMetadata } catch (error) { console.log('error in parsing metadata event content :>> ', error) return null } } /** * Function will not sign provided event if the SIG exists */ public publishMetadataEvent = async (event: Event) => { let signedMetadataEvent = event if (event.sig.length < 1) { const timestamp = Math.floor(Date.now() / 1000) // Metadata event to publish to the wss://purplepag.es relay const newMetadataEvent: Event = { ...event, created_at: timestamp } signedMetadataEvent = await this.nostrController.signEvent(newMetadataEvent) } await this.nostrController .publishEvent(signedMetadataEvent, [this.specialMetadataRelay]) .then((relays) => { toast.success(`Metadata event published on: ${relays.join('\n')}`) this.handleNewMetadataEvent(signedMetadataEvent as VerifiedEvent) }) .catch((err) => { toast.error(err.message) }) } public getNostrJoiningBlockNumber = async ( hexKey: string ): Promise => { const relaySet = await this.findRelayListMetadata(hexKey) const userRelays: string[] = [] // find user's relays if (relaySet.write.length > 0) { userRelays.push(...relaySet.write) } else { const metadata = await this.findMetadata(hexKey) if (!metadata) return null const metadataContent = this.extractProfileMetadataContent(metadata) if (metadataContent?.nip05) { const nip05Profile = await queryNip05(metadataContent.nip05) if (nip05Profile && nip05Profile.pubkey === hexKey) { userRelays.push(...nip05Profile.relays) } } } if (userRelays.length === 0) return null // filter for finding user's first kind 0 event const eventFilter: Filter = { kinds: [kinds.Metadata], authors: [hexKey] } const pool = new SimplePool() // find user's kind 0 events published on user's relays const events = await pool.querySync(userRelays, eventFilter) if (events && events.length) { // sort events by created_at time in ascending order events.sort((a, b) => a.created_at - b.created_at) // get first ever event published on user's relays const event = events[0] const { created_at } = event // initialize job request const jobEventTemplate: EventTemplate = { content: '', created_at: Math.round(Date.now() / 1000), kind: 68001, tags: [ ['i', `${created_at * 1000}`], ['j', 'blockChain-block-number'] ] } // sign job request event const jobSignedEvent = await this.nostrController.signEvent(jobEventTemplate) const relays = [ 'wss://relay.damus.io', 'wss://relay.primal.net', 'wss://relayable.org' ] // publish job request await this.nostrController.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) const encodedEventPointer = nip19.neventEncode({ id: event.id, relays: userRelays, author: event.pubkey, kind: event.kind }) return { block: parseInt(dvmJobResult), encodedEventPointer } } return null } public validate = (event: Event) => validateEvent(event) && verifyEvent(event) public getEmptyMetadataEvent = (): Event => { return { content: '', created_at: new Date().valueOf(), id: '', kind: 0, pubkey: '', sig: '', tags: [] } } }