import axios from 'axios' 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 { AuthState } from '../store/auth/types' import { RelaysState } from '../store/relays/types' import store from '../store/store' import { Meta, Rumor, Sigit, SignedEvent } from '../types' import { getHash } from './hash' import { parseJson } from './string' /** * @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}` } const TWO_DAYS = 2 * 24 * 60 * 60 export const now = () => Math.round(Date.now() / 1000) export const randomNow = () => Math.round(now() - Math.random() * TWO_DAYS) /** * 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: EventTemplate, 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 = ( event: Event, receiver: string, difficulty: number = 10 ) => { // 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(event, randomKey, receiver) // Initialize nonce and leadingZeroes for PoW calculation let nonce = 0 let leadingZeroes = 0 // 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 unsignedEvent: UnsignedEvent = { kind: 1059, // Event kind content, // Encrypted content pubkey, // Public key of the creator created_at: randomNow(), // 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(unsignedEvent) // 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(unsignedEvent, randomKey) } // Increment the nonce for the next iteration nonce++ } } export const getUsersAppData = async () => { 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) { // todo: use metadata controller 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 // Ensure relay list is not empty if (relaySet.write.length === 0) return 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('sigit' + 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 === '{}') { return {} } 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<{ [uuid: string]: Sigit }>(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 return parsedContent } export const updateUsersAppData = async (fileUrl: string, meta: Meta) => { const appData = await getUsersAppData() if (!appData) return null if (meta.uuid in appData) { // update meta only if income meta is more recent than already existing one const existingMeta = appData[meta.uuid].meta if (existingMeta.modifiedAt < meta.modifiedAt) { appData[meta.uuid] = { fileUrl, meta } } } else { appData[meta.uuid] = { fileUrl, meta } } appData[meta.uuid] = { fileUrl, meta } const usersPubkey = (store.getState().auth as AuthState).usersPubkey! const nostrController = NostrController.getInstance() const encryptedContent = await nostrController .nip04Encrypt(usersPubkey, JSON.stringify(appData)) .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('sigit' + 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) const publishResult = await nostrController .publishEvent(signedEvent, writeRelays) .catch((errResults) => { console.log('err :>> ', errResults) toast.error('An error occurred while publishing App data') errResults.forEach((errResult: any) => { toast.error( `Publishing to ${errResult.relay} caused the following error: ${errResult.error}` ) }) return null }) if (!publishResult) return null return signedEvent } export const subscribeForSigits = async (pubkey: string) => { // Get relay list metadata const metadataController = new MetadataController() const relaySet = await metadataController .findRelayListMetadata(pubkey) .catch((err) => { 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 const filter: Filter = { kinds: [1059], '#p': [pubkey] } const pool = new SimplePool() pool.subscribeMany(relaySet.read, [filter], { onevent: (event) => { processReceivedEvent(event) } }) } const processReceivedEvent = async (event: Event, difficulty: number = 10) => { // validate PoW // Count the number of leading zero bits in the hash const leadingZeroes = countLeadingZeroes(event.id) if (leadingZeroes < difficulty) return const nostrController = NostrController.getInstance() const stringifiedSealEvent = await nostrController.nip44Decrypt( event.pubkey, event.content ) const sealEvent = await parseJson(stringifiedSealEvent) const stringifiedRumor = await nostrController.nip44Decrypt( sealEvent.pubkey, sealEvent.content ) const rumor = await parseJson(stringifiedRumor) if (rumor.content === 'sigit-notification') { const uTag = rumor.tags.find((tag) => tag[0] === 'u') if (!uTag) return const fileUrl = uTag[1] const mTag = rumor.tags.find((tag) => tag[0] === 'm') if (!mTag) return const metaString = mTag[1] const meta = await parseJson(metaString) updateUsersAppData(fileUrl, meta) } } export const sendNotification = async ( receiver: string, meta: Meta, fileUrl: string ) => { const usersPubkey = (store.getState().auth as AuthState).usersPubkey! const unsignedEvent: UnsignedEvent = { kind: kinds.ShortTextNote, pubkey: usersPubkey, content: 'sigit-notification', tags: [ ['u', fileUrl], ['m', JSON.stringify(meta)] ], created_at: now() } const rumor: Rumor = { ...unsignedEvent, id: getEventHash(unsignedEvent) } const nostrController = NostrController.getInstance() const seal = await nostrController.createSeal(rumor, receiver) const wrappedEvent = createWrap(seal, receiver) // Get relay list metadata const metadataController = new MetadataController() const relaySet = await metadataController .findRelayListMetadata(receiver) .catch((err) => { 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 // Publish the notification event to the recipient's read relays await nostrController .publishEvent(wrappedEvent, relaySet.read) .catch((errResults) => { console.log( `An error occurred while publishing notification event for ${hexToNpub(receiver)}`, errResults ) return null }) }