import { bytesToHex, hexToBytes } from '@noble/hashes/utils' import axios from 'axios' import _ from 'lodash' import { Event, EventTemplate, Filter, SimplePool, UnsignedEvent, finalizeEvent, generateSecretKey, getEventHash, getPublicKey, kinds, nip19, nip44, verifyEvent } from 'nostr-tools' import { toast } from 'react-toastify' import { NIP05_REGEX } from '../constants' import { MetadataController, NostrController } from '../controllers' import { updateProcessedGiftWraps, updateUserAppData as updateUserAppDataAction } from '../store/actions' import { AuthState, Keys } from '../store/auth/types' import { RelaysState } from '../store/relays/types' import store from '../store/store' import { Meta, SignedEvent, UserAppData } from '../types' import { getHash } from './hash' import { parseJson, removeLeadingSlash } from './string' import { timeout } from './utils' /** * @param hexKey hex private or public key * @returns whether or not is key valid */ const validateHex = (hexKey: string) => { return hexKey.match(/^[a-f0-9]{64}$/) } /** * NPUB provided - it will convert NPUB to HEX * HEX provided - it will return HEX * * @param pubKey in NPUB, HEX format * @returns HEX format */ export const npubToHex = (pubKey: string): string | null => { // If key is NPUB if (pubKey.startsWith('npub1')) { try { return nip19.decode(pubKey).data as string } catch (error) { return null } } // valid hex key if (validateHex(pubKey)) return pubKey // Not a valid hex key return null } /** * If NSEC key is provided function will convert it to HEX * If HEX key Is provided function will validate the HEX format and return * * @param nsec or private key in HEX format */ export const nsecToHex = (nsec: string): string | null => { // If key is NSEC if (nsec.startsWith('nsec')) { try { return nip19.decode(nsec).data as string } catch (error) { return null } } // since it's not NSEC key we check if it's a valid hex key if (validateHex(nsec)) return nsec return null } export const hexToNpub = (hexPubkey: string): `npub1${string}` => { if (hexPubkey.startsWith('npub1')) return hexPubkey as `npub1${string}` return nip19.npubEncode(hexPubkey) } export const verifySignedEvent = (event: SignedEvent) => { const isGood = verifyEvent(event) if (!isGood) { throw new Error( 'Signed event did not pass verification. Check sig, id and pubkey.' ) } } /** * Function to query NIP-05 data and return the public key and relays. * * @param {string} nip05 - The NIP-05 identifier in the format "name@domain". * @returns {Promise<{ pubkey: string, relays: string[] }>} - The public key and an array of relay URLs. * @throws Will throw an error if the NIP-05 identifier is invalid or if there is an issue with the network request. */ export const queryNip05 = async ( nip05: string ): Promise<{ pubkey: string relays: string[] }> => { const match = nip05.match(NIP05_REGEX) // Throw an error if the NIP-05 identifier is invalid if (!match) throw new Error('Invalid nip05') // Destructure the match result, assigning default value '_' to name if not provided const [name = '_', domain] = match // Construct the URL to query the NIP-05 data const url = `https://${domain}/.well-known/nostr.json?name=${name}` // Perform the network request to get the NIP-05 data const res = await axios(url) .then((res) => { return res.data }) .catch((err) => { console.log('err :>> ', err) throw err }) // Extract the public key from the response data const pubkey = res.names[name] const relays: string[] = [] // If a public key is found if (pubkey) { // Function to add relays if they exist and are not empty const addRelays = (relayList?: string[]) => { if (relayList && relayList.length > 0) { relays.push(...relayList) } } // Check for user-specific relays in the NIP-46 section of the response data if (res.nip46) { addRelays(res.nip46[pubkey] as string[]) } // Check for user-specific relays in the relays section of the response data if not found in NIP-46 if (relays.length === 0 && res.relays) { addRelays(res.relays[pubkey] as string[]) } // If no user-specific relays are found, check for root user relays if (relays.length === 0) { const root = res.names['_'] if (root) { // Check for root user relays in both NIP-46 and relays sections addRelays(res.nip46?.[root] as string[]) addRelays(res.relays?.[root] as string[]) } } } // Return the public key and the array of relays return { pubkey, relays } } export const base64EncodeSignedEvent = (event: SignedEvent) => { try { const authEventSerialized = JSON.stringify(event) const token = btoa(authEventSerialized) return token } catch (error) { throw new Error('An error occurred in JSON.stringify of signedAuthEvent') } } export const base64DecodeAuthToken = (authToken: string): SignedEvent => { const decodedToken = atob(authToken) try { const signedEvent = JSON.parse(decodedToken) return signedEvent } catch (error) { throw new Error('An error occurred in JSON.parse of the auth token') } } /** * @param pubkey in hex or npub format * @returns robohash.org url for the avatar */ export const getRoboHashPicture = ( pubkey?: string, set: number = 1 ): string => { if (!pubkey) return '' const npub = hexToNpub(pubkey) return `https://robohash.org/${npub}.png?set=set${set}` } export const now = () => Math.round(Date.now() / 1000) /** * Generate nip44 conversation key * @param privateKey * @param publicKey * @returns */ export const nip44ConversationKey = ( privateKey: Uint8Array, publicKey: string ) => nip44.v2.utils.getConversationKey(privateKey, publicKey) export const nip44Encrypt = ( data: UnsignedEvent, privateKey: Uint8Array, publicKey: string ) => nip44.v2.encrypt( JSON.stringify(data), nip44ConversationKey(privateKey, publicKey) ) export const nip44Decrypt = (data: Event, privateKey: Uint8Array) => JSON.parse( nip44.v2.decrypt( data.content, nip44ConversationKey(privateKey, data.pubkey) ) ) // Function to count leading zero bits export const countLeadingZeroes = (hex: string) => { let count = 0 for (let i = 0; i < hex.length; i++) { const nibble = parseInt(hex[i], 16) if (nibble === 0) { count += 4 } else { count += Math.clz32(nibble) - 28 break } } return count } /** * Function to create a wrapped event with PoW * @param event Original event to be wrapped * @param receiver Public key of the receiver * @param difficulty PoW difficulty level (default is 20) * @returns */ // export const createWrap = (unsignedEvent: UnsignedEvent, receiver: string) => { // Generate a random secret key and its corresponding public key const randomKey = generateSecretKey() const pubkey = getPublicKey(randomKey) // Encrypt the event content using nip44 encryption const content = nip44Encrypt(unsignedEvent, randomKey, receiver) // Initialize nonce and leadingZeroes for PoW calculation let nonce = 0 let leadingZeroes = 0 const difficulty = Math.floor(Math.random() * 10) + 5 // random number between 5 & 10 // Loop until a valid PoW hash is found // eslint-disable-next-line no-constant-condition while (true) { // Create an unsigned event with the necessary fields const event: UnsignedEvent = { kind: 1059, // Event kind content, // Encrypted content pubkey, // Public key of the creator created_at: now(), // Current timestamp tags: [ // Tags including receiver and nonce ['p', receiver], ['nonce', nonce.toString(), difficulty.toString()] ] } // Calculate the SHA-256 hash of the unsigned event const hash = getEventHash(event) // Count the number of leading zero bits in the hash leadingZeroes = countLeadingZeroes(hash) // Check if the leading zero bits meet the required difficulty if (leadingZeroes >= difficulty) { // Finalize the event (sign it) and return the result return finalizeEvent(event, randomKey) } // Increment the nonce for the next iteration nonce++ } } export const getUsersAppData = async (): Promise => { const relays: string[] = [] const usersPubkey = (store.getState().auth as AuthState).usersPubkey! const relayMap = store.getState().relays?.map const nostrController = NostrController.getInstance() // check if relaysMap in redux store is undefined if (!relayMap) { const metadataController = new MetadataController() const relaySet = await metadataController .findRelayListMetadata(usersPubkey) .catch((err) => { console.log( `An error occurred while finding relay list metadata for ${hexToNpub(usersPubkey)}`, err ) return null }) // Return if metadata retrieval failed if (!relaySet) return null // Ensure relay list is not empty if (relaySet.write.length === 0) return null relays.push(...relaySet.write) } else { // filter write relays from user's relayMap stored in redux store const writeRelays = Object.keys(relayMap).filter( (key) => relayMap[key].write ) relays.push(...writeRelays) } // generate an identifier for user's nip78 const hash = await getHash('938' + usersPubkey) if (!hash) return null const filter: Filter = { kinds: [kinds.Application], '#d': [hash] } const encryptedContent = await nostrController .getEvent(filter, relays) .then((event) => { if (event) return event.content // if person is using sigit for first time its possible that event is null // so we'll return empty stringified object return '{}' }) .catch((err) => { console.log(`An error occurred in finding kind 30078 event`, err) toast.error( 'An error occurred in finding kind 30078 event for data storage' ) return null }) if (!encryptedContent) return null if (encryptedContent === '{}') { const secret = generateSecretKey() const pubKey = getPublicKey(secret) return { sigits: {}, processedGiftWraps: [], blossomUrls: [], keyPair: { private: bytesToHex(secret), public: pubKey } } } const decrypted = await nostrController .nip04Decrypt(usersPubkey, encryptedContent) .catch((err) => { console.log('An error occurred while decrypting app data', err) toast.error('An error occurred while decrypting app data') return null }) if (!decrypted) return null const parsedContent = await parseJson<{ blossomUrls: string[] keyPair: Keys }>(decrypted).catch((err) => { console.log( 'An error occurred in parsing the content of kind 30078 event', err ) toast.error('An error occurred in parsing the content of kind 30078 event') return null }) if (!parsedContent) return null const { blossomUrls, keyPair } = parsedContent if (blossomUrls.length === 0) return null const dataFromBlossom = await getUserAppDataFromBlossom( blossomUrls[0], keyPair.private ) if (!dataFromBlossom) return null const { sigits, processedGiftWraps } = dataFromBlossom return { blossomUrls, keyPair, sigits, processedGiftWraps } } export const updateUsersAppData = async (meta: Meta) => { const appData = store.getState().userAppData if (!appData || !appData.keyPair) return null const sigits = _.cloneDeep(appData.sigits) const createSignatureEvent = await parseJson( meta.createSignature ).catch((err) => { console.log('err in parsing the createSignature event:>> ', err) toast.error( err.message || 'error occurred in parsing the create signature event' ) return null }) if (!createSignatureEvent) return null const id = createSignatureEvent.id let isUpdated = false // check if sigit already exists if (id in sigits) { // update meta only if incoming meta is more recent // than already existing one const existingMeta = sigits[id] if (existingMeta.modifiedAt < meta.modifiedAt) { sigits[id] = meta isUpdated = true } } else { sigits[id] = meta isUpdated = true } if (!isUpdated) return null const blossomUrls = [...appData.blossomUrls] const newBlossomUrl = await uploadUserAppDataToBlossom( sigits, appData.processedGiftWraps, appData.keyPair.private ).catch((err) => { console.log( 'An error occurred in uploading user app data file to blossom server', err ) toast.error( 'An error occurred in uploading user app data file to blossom server' ) return null }) if (!newBlossomUrl) return null // insert new blossom url at the start of the array blossomUrls.unshift(newBlossomUrl) // only keep last 10 blossom urls, delete older ones if (blossomUrls.length > 10) { const filesToDelete = blossomUrls.splice(10) filesToDelete.forEach((url) => { deleteBlossomFile(url, appData.keyPair!.private).catch((err) => { console.log( 'An error occurred in removing old file of user app data from blossom server', err ) }) }) } const usersPubkey = (store.getState().auth as AuthState).usersPubkey! // encrypt content for storing in kind 30078 event const nostrController = NostrController.getInstance() const encryptedContent = await nostrController .nip04Encrypt( usersPubkey, JSON.stringify({ blossomUrls, keyPair: appData.keyPair }) ) .catch((err) => { console.log( 'An error occurred in encryption of content for app data', err ) toast.error( err.message || 'An error occurred in encryption of content for app data' ) return null }) if (!encryptedContent) return null // generate the identifier for user's appData event const hash = await getHash('938' + usersPubkey) if (!hash) return null const updatedEvent: UnsignedEvent = { kind: kinds.Application, pubkey: usersPubkey!, created_at: now(), tags: [['d', hash]], content: encryptedContent } const signedEvent = await nostrController .signEvent(updatedEvent) .catch((err) => { console.log('An error occurred in signing event', err) toast.error(err.message || 'An error occurred in signing event') return null }) if (!signedEvent) return null const relayMap = (store.getState().relays as RelaysState).map! const writeRelays = Object.keys(relayMap).filter((key) => relayMap[key].write) console.log(`publishing event kind: ${kinds.Application}`) const publishResult = await Promise.race([ nostrController.publishEvent(signedEvent, writeRelays), timeout(1000 * 30) ]).catch((err) => { console.log('err :>> ', err) if (err.message === 'Timeout') { toast.error('Timeout occurred in publishing updated app data') } else if (Array.isArray(err)) { err.forEach((errResult) => { toast.error( `Publishing to ${errResult.relay} caused the following error: ${errResult.error}` ) }) } else { toast.error( 'An unexpected error occurred in publishing updated app data ' ) } return null }) if (!publishResult) return null // update redux store store.dispatch( updateUserAppDataAction({ sigits, blossomUrls, processedGiftWraps: [...appData.processedGiftWraps], keyPair: { ...appData.keyPair } }) ) return signedEvent } const deleteBlossomFile = async (url: string, privateKey: string) => { const pathname = new URL(url).pathname const hash = removeLeadingSlash(pathname) // Define event metadata for authorization const event: EventTemplate = { kind: 24242, content: 'Authorize Upload', created_at: now(), tags: [ ['t', 'delete'], ['expiration', String(now() + 60 * 5)], // Set expiration time to 5 minutes from now ['x', hash] ] } const authEvent = finalizeEvent(event, hexToBytes(privateKey)) // delete the file stored on file storage service using Axios const response = await axios.delete(url, { headers: { Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header } }) console.log('response.data :>> ', response.data) } /** * Function to upload user application data to the Blossom server. * @param sigits - An object containing metadata for the user application data. * @param processedGiftWraps - An array of processed gift wrap IDs. * @param privateKey - The private key used for encryption. * @returns A promise that resolves to the URL of the uploaded file. */ const uploadUserAppDataToBlossom = async ( sigits: { [key: string]: Meta }, processedGiftWraps: string[], privateKey: string ) => { // Create an object containing the sigits and processed gift wraps const obj = { sigits, processedGiftWraps } // Convert the object to a JSON string const stringified = JSON.stringify(obj) // Convert the private key from hex to bytes const secretKey = hexToBytes(privateKey) // Encrypt the JSON string using the secret key const encrypted = nip44.v2.encrypt( stringified, nip44ConversationKey(secretKey, getPublicKey(secretKey)) ) // Create a blob from the encrypted data const blob = new Blob([encrypted], { type: 'application/octet-stream' }) // Create a file from the blob const file = new File([blob], 'encrypted.txt', { type: 'application/octet-stream' }) // Define event metadata for authorization const event: EventTemplate = { kind: 24242, content: 'Authorize Upload', created_at: now(), tags: [ ['t', 'upload'], ['expiration', String(now() + 60 * 5)], // Set expiration time to 5 minutes from now ['name', file.name], ['size', String(file.size)] ] } // Finalize the event with the private key const authEvent = finalizeEvent(event, hexToBytes(privateKey)) // URL of the file storage service const FILE_STORAGE_URL = 'https://blossom.sigit.io' // Upload the file to the file storage service using Axios const response = await axios.put(`${FILE_STORAGE_URL}/upload`, file, { headers: { Authorization: 'Nostr ' + btoa(JSON.stringify(authEvent)) // Set authorization header } }) // Return the URL of the uploaded file return response.data.url as string } /** * Function to retrieve and decrypt user application data from Blossom server. * @param url - The URL to fetch the encrypted data from. * @param privateKey - The private key used for decryption. * @returns A promise that resolves to the decrypted and parsed user application data. */ const getUserAppDataFromBlossom = async (url: string, privateKey: string) => { // Initialize errorCode to track HTTP error codes let errorCode = 0 // Fetch the encrypted data from the provided URL const encrypted = await axios .get(url, { responseType: 'blob' // Expect a blob response }) .then(async (res) => { // Convert the blob response to a File object const file = new File([res.data], 'encrypted.txt') // Read the text content from the file const text = await file.text() return text }) .catch((err) => { // Log and display an error message if the request fails console.error(`error occurred in getting file from ${url}`, err) toast.error(err.message || `error occurred in getting file from ${url}`) // Set errorCode to the HTTP status code if available if (err.request) { const { status } = err.request errorCode = status } return null }) // Return a default value if the requested resource is not found (404) if (errorCode === 404) { return { sigits: {}, processedGiftWraps: [] } } // Return null if the encrypted data could not be retrieved if (!encrypted) return null // Convert the private key from hex to bytes const secret = hexToBytes(privateKey) // Get the public key corresponding to the private key const pubkey = getPublicKey(secret) // Decrypt the encrypted data using the secret and public key const decrypted = nip44.v2.decrypt( encrypted, nip44ConversationKey(secret, pubkey) ) // Parse the decrypted JSON content const parsedContent = await parseJson<{ sigits: { [key: string]: Meta } processedGiftWraps: string[] }>(decrypted).catch((err) => { // Log and display an error message if parsing fails console.log( 'An error occurred in parsing the user app data content from blossom server', err ) toast.error( 'An error occurred in parsing the user app data content from blossom server' ) return null }) // Return the parsed content return parsedContent } /** * Function to subscribe to sigits notifications for a specified public key. * @param pubkey - The public key to subscribe to. * @returns A promise that resolves when the subscription is successful. */ export const subscribeForSigits = async (pubkey: string) => { // Instantiate the MetadataController to retrieve relay list metadata const metadataController = new MetadataController() const relaySet = await metadataController .findRelayListMetadata(pubkey) .catch((err) => { // Log an error if retrieving relay list metadata fails console.log( `An error occurred while finding relay list metadata for ${hexToNpub(pubkey)}`, err ) return null }) // Return if metadata retrieval failed if (!relaySet) return // Ensure relay list is not empty if (relaySet.read.length === 0) return // Define the filter for the subscription const filter: Filter = { kinds: [1059], '#p': [pubkey] } // Instantiate a new SimplePool for the subscription const pool = new SimplePool() // Subscribe to the specified relays with the defined filter return pool.subscribeMany(relaySet.read, [filter], { // Define a callback function to handle received events onevent: (event) => { processReceivedEvent(event) // Process the received event } }) } const processReceivedEvent = async (event: Event, difficulty: number = 5) => { const processedEvents = (store.getState().userAppData as UserAppData) .processedGiftWraps if (processedEvents.includes(event.id)) return store.dispatch(updateProcessedGiftWraps([...processedEvents, event.id])) // validate PoW // Count the number of leading zero bits in the hash const leadingZeroes = countLeadingZeroes(event.id) if (leadingZeroes < difficulty) return // decrypt the content of gift wrap event const nostrController = NostrController.getInstance() const decrypted = await nostrController.nip44Decrypt( event.pubkey, event.content ) const internalUnsignedEvent = await parseJson(decrypted).catch( (err) => { console.log( 'An error occurred in parsing the internal unsigned event', err ) return null } ) if (!internalUnsignedEvent || internalUnsignedEvent.kind !== 938) return const meta = await parseJson(internalUnsignedEvent.content).catch( (err) => { console.log( 'An error occurred in parsing the internal unsigned event', err ) return null } ) if (!meta) return updateUsersAppData(meta) } /** * Function to send a notification to a specified receiver. * @param receiver - The recipient's public key. * @param meta - Metadata associated with the notification. */ export const sendNotification = async (receiver: string, meta: Meta) => { // Retrieve the user's public key from the state const usersPubkey = (store.getState().auth as AuthState).usersPubkey! // Create an unsigned event object with the provided metadata const unsignedEvent: UnsignedEvent = { kind: 938, pubkey: usersPubkey, content: JSON.stringify(meta), tags: [], created_at: now() } // Wrap the unsigned event with the receiver's information const wrappedEvent = createWrap(unsignedEvent, receiver) // Instantiate the MetadataController to retrieve relay list metadata const metadataController = new MetadataController() const relaySet = await metadataController .findRelayListMetadata(receiver) .catch((err) => { // Log an error if retrieving relay list metadata fails console.log( `An error occurred while finding relay list metadata for ${hexToNpub(receiver)}`, err ) return null }) // Return if metadata retrieval failed if (!relaySet) return // Ensure relay list is not empty if (relaySet.read.length === 0) return console.log('Publishing notifications') // Publish the notification event to the recipient's read relays const nostrController = NostrController.getInstance() // Attempt to publish the event to the relays, with a timeout of 2 minutes await Promise.race([ nostrController.publishEvent(wrappedEvent, relaySet.read), timeout(1000 * 30) ]).catch((err) => { // Log an error if publishing the notification event fails console.log( `An error occurred while publishing notification event for ${hexToNpub(receiver)}`, err ) throw err }) }