UNPKG

cyphertap

Version:

Nostr, Lightning and Ecash on a single Button component

472 lines (471 loc) 16.4 kB
// src/lib/client/stores/nostr.ts import NDKSvelte from '@nostr-dev-kit/ndk-svelte'; import NDK, { NDKPrivateKeySigner, NDKNip07Signer, NDKEvent, NDKKind, normalizeRelayUrl, } from '@nostr-dev-kit/ndk'; import { writable, derived, get } from 'svelte/store'; import { BROWSER as browser } from 'esm-env'; import NDKCacheAdapterDexie from "@nostr-dev-kit/ndk-cache-dexie"; import { startUnpublishedEventsMonitor, stopUnpublishedEventsMonitor } from '../services/unpublishedEvents.js'; import { createDebug } from '../utils/debug.js'; import { initWallet } from './wallet.js'; import { navigateTo } from './navigation.js'; // Create debug logger for Nostr functionality const debug = createDebug('nostr'); // const dConnect = d.extend('connect'); // const dRelay = d.extend('relay'); // const dAuth = d.extend('auth'); // const dEvent = d.extend('event'); // Create writable stores export const ndkInstance = writable(null); export const currentUser = writable(null); export const isConnecting = writable(false); export const loginError = writable(null); // Local storage keys export const ENCRYPTED_KEY = 'ncrypt'; export const EXTENSION_LOGIN = 'nip07'; /** * Initialize NDK with a specific user context * @param signer - The signer to use * @returns An object containing the NDK instance and user */ export async function initNDK(signer) { const d = debug.extend('initNDK'); d.log('Initializing NDK with signer'); // Check if we already have an instance const existingNdk = get(ndkInstance); if (existingNdk && existingNdk.signer) { d.log('NDK already initialized with a signer, reusing existing instance'); return { ndk: existingNdk, user: get(currentUser) || (await existingNdk.signer.user()) }; } // Get user from signer d.log('Getting user from signer'); const user = await signer.user(); if (!user) { d.error('Failed to get user from signer'); throw new Error('Failed to get user'); } d.log(`User retrieved: ${user.npub.slice(0, 8)}...${user.npub.slice(-4)}`); // init dexie cache d.log(`Initializing Dexie cache for user ${user.npub.slice(0, 8)}`); const dexieAdapter = new NDKCacheAdapterDexie({ dbName: dbName(user.npub), saveSig: true }); d.log('Creating new NDK instance or updating existing one'); const ndk = existingNdk || new NDKSvelte({ explicitRelayUrls: [ 'wss://relay.cypherflow.ai' // Add other default relays as needed ], enableOutboxModel: false, autoFetchUserMutelist: false, signer: signer, cacheAdapter: dexieAdapter, }); // Update stores (early to ensure UI can display loading state) d.log('Updating NDK and user stores'); ndkInstance.set(ndk); currentUser.set(user); // Connect to relays try { d.log('Connecting to relays'); await ndk.connect(); d.log('Successfully connected to relays'); // Set up relay event listeners d.log('Setting up relay event listeners'); relaySetup(ndk); } catch (err) { d.error('Failed to connect to relays:', err); d.warn('Continuing without relay connection - will work with cached data only'); // Continue anyway - we might still be able to work with locally cached data } d.log('NDK initialization complete'); return { ndk, user }; } function dbName(npub) { return `nostr-cache-${npub.slice(0, 8)}`; } /** * Set up listeners for relay connection events */ function relaySetup(ndk) { const d = debug.extend("relaySetup"); d.log('Registering event listeners'); const handleRelayConnect = (relay) => { d.log(`Connected to: ${relay.url}`); // This will trigger updates to derived stores ndkInstance.set(ndk); }; const handleRelayDisconnect = (relay) => { d.log(`Disconnected from: ${relay.url}`); // This will trigger updates to derived stores ndkInstance.set(ndk); }; ndk.pool.on('relay:connect', handleRelayConnect); ndk.pool.on('relay:disconnect', handleRelayDisconnect); d.log('Relay event listeners set up successfully'); // Return cleanup function return () => { d.log('Cleaning up relay event listeners'); ndk.pool.off('relay:connect', handleRelayConnect); ndk.pool.off('relay:disconnect', handleRelayDisconnect); }; } /** * Generate a new keypair for the user * @returns Private key string */ export function generateNewKeypair() { const d = debug.extend("generateNewKeyPair"); d.log('Generating new Nostr keypair'); const signer = NDKPrivateKeySigner.generate(); const privateKey = signer.privateKey; d.log('New keypair generated successfully'); return privateKey; } /** * Login with a private key (nsec) */ export async function privateKeyLogin(nsec) { const d = debug.extend("privateKeyLogin"); try { isConnecting.set(true); loginError.set(null); // Create private key signer let signer; try { d.log('Creating signer with private key...'); signer = new NDKPrivateKeySigner(nsec); d.log('Signer created successfully'); } catch (error) { d.error('Invalid private key format:', error); throw new Error('Invalid private key format'); } // Initialize NDK with private key signer await initNDK(signer); // Save the key securely d.log('Securely storing private key'); storePrivateKey(nsec); d.log('Private key stored securely'); d.log('Login with private key completed successfully'); return true; } catch (error) { d.error('Login with private key failed:', error); loginError.set(error instanceof Error ? error.message : 'Login failed'); throw error; } finally { isConnecting.set(false); } } /** * Login with a NIP-07 extension */ export async function nip07Login() { const d = debug.extend("nip07Login"); try { isConnecting.set(true); loginError.set(null); if (!browser || !window.nostr) { d.error('No Nostr extension found'); throw new Error('No Nostr extension found. Please install Alby, nos2x, or another NIP-07 extension.'); } // Create NIP-07 signer (for browser extensions) d.log('Creating NIP-07 signer from browser extension'); const nip07signer = new NDKNip07Signer(); d.log('NIP-07 signer created successfully'); // Initialize NDK with nip-07 signer await initNDK(nip07signer); // Store marker for extension login if (browser) { d.log('Storing extension login marker on local storage'); localStorage.setItem(EXTENSION_LOGIN, 'true'); } d.log('Login with extension completed successfully'); return true; } catch (error) { d.error('Login with extension failed:', error); loginError.set(error instanceof Error ? error.message : 'Login failed'); throw error; } finally { isConnecting.set(false); } } /** * Securely store the user's private key */ function storePrivateKey(privateKey) { const d = debug.extend("storePrivateKey"); d.log('Storing private key'); if (!browser) { d.warn('Not in browser environment, skipping key storage'); return; } localStorage.setItem(ENCRYPTED_KEY, privateKey); d.log('Private key stored in local storage'); } /** * Unified login function that handles both login methods * Keeps all login logic in the Nostr service */ export async function login(options) { const d = debug.extend("login"); d.log(`Login with method: ${options.method}`); try { isConnecting.set(true); loginError.set(null); // Perform login based on method if (options.method === 'private-key' && options.privateKey) { await privateKeyLogin(options.privateKey); } else if (options.method === 'nip-07') { await nip07Login(); } else { throw new Error('Invalid login method or missing required parameters'); } d.log(`Login successful!`); navigateTo("main"); // Start unpublished events monitor startUnpublishedEventsMonitor(); await initWallet(); return; } catch (error) { d.error('Login failed:', error); loginError.set(error instanceof Error ? error.message : 'Login failed'); throw error; } finally { isConnecting.set(false); } } /** * Try to auto-login with stored credentials * This function only handles the NDK initialization, not other components */ export async function autoLogin() { const d = debug.extend("autoLogin"); if (!browser) { d.warn('Not in browser environment, skipping auto-login'); return null; } try { // Check if we have a stored key const storedKey = localStorage.getItem(ENCRYPTED_KEY); const extensionMarker = localStorage.getItem(EXTENSION_LOGIN); if (storedKey) { d.log('Found stored private key, attempting login'); // In a real app, you'd decrypt this key first return await login({ method: 'private-key', privateKey: storedKey }); } else if (extensionMarker === 'true') { d.log('Found extension login marker, attempting extension login'); // Check if extension is available if (!window.nostr) { d.error('Extension not available but marker exists'); throw new Error('Nostr extension not found. Please install or enable your extension.'); } return await login({ method: "nip-07" }); } else { d.log('No stored credentials found, auto-login skipped'); } } catch (error) { d.error('Auto-login failed:', error); // Clear potentially corrupted credentials d.log('Clearing potentially corrupted credentials'); localStorage.removeItem(ENCRYPTED_KEY); localStorage.removeItem(EXTENSION_LOGIN); } return null; } /** * Clear the Nostr IndexedDB cache for a specific user * @param npub The user's public key */ function clearNostrCache(npub) { const d = debug.extend("clearNostrCache"); if (!browser) return Promise.resolve(); const db = dbName(npub); d.log(`Deleting IndexedDB database: ${db}`); return new Promise((resolve, reject) => { const deleteRequest = window.indexedDB.deleteDatabase(db); deleteRequest.onerror = (event) => { const error = `Error deleting IndexedDB database: ${db}`; d.error(error, event); reject(new Error(error)); }; deleteRequest.onsuccess = () => { d.log(`Successfully deleted IndexedDB database: ${db}`); resolve(); }; }); } /** * Logout function */ export function logout(clearCache = false) { const d = debug.extend("logout"); d.log('Logging out user'); const ndk = get(ndkInstance); const user = get(currentUser); // Stop unpublished events monitor d.log('Stopping unpublished events monitor'); stopUnpublishedEventsMonitor(); // Delete the user's cache database if requested if (browser && user && clearCache) { clearNostrCache(user.npub); } if (ndk) { d.log('Clearing NDK signer and active user'); ndk.signer = undefined; ndk.activeUser = undefined; ndkInstance.set(null); } currentUser.set(null); // Clear stored key if (browser) { d.log('Removing stored private key and extension marker if any'); localStorage.removeItem(ENCRYPTED_KEY); localStorage.removeItem(EXTENSION_LOGIN); } d.log('Logout complete'); } export function getNDK() { const ndk = get(ndkInstance); if (!ndk) { debug.error('Attempted to get NDK instance but it is not initialized'); throw new Error('NDK not initialized'); } return ndk; } /** * Add a relay to the NDK instance and publish the updated relay list * @param url The relay URL to add */ export async function addRelay(url) { const d = debug.extend("addRelay"); d.log(`Adding relay: ${url}`); if (!url.trim()) { d.error('Relay URL cannot be empty'); throw new Error('Relay URL cannot be empty'); } try { const ndk = getNDK(); const normalizedUrl = normalizeRelayUrl(url.trim()); d.log(`Normalized relay URL: ${normalizedUrl}`); d.log('Adding relay to NDK pool'); ndk.addExplicitRelay(normalizedUrl); d.log('Relay added to local NDK instance'); // Make sure to update the store to trigger derived stores d.log('Updating NDK store'); ndkInstance.set(ndk); d.log('Publishing updated relay list'); await publishRelayList(); d.log('New relay list published successfully'); return normalizedUrl; } catch (error) { d.error('Error adding relay:', error); throw error; } } /** * Remove a relay from the NDK instance and publish the updated relay list * @param url The relay URL to remove */ export async function removeRelay(url) { const d = debug.extend("removeRelay"); d.log(`Removing relay: ${url}`); try { const ndk = getNDK(); d.log('Removing relay from NDK pool'); ndk.pool.removeRelay(url); d.log('Relay removed from local NDK instance'); // Update the store to trigger derived stores d.log('Updating NDK store'); ndkInstance.set(ndk); d.log('Publishing updated relay list'); await publishRelayList(); d.log('Updated relay list published successfully'); } catch (error) { d.error('Error removing relay:', error); throw error; } } /** * Publish the current relay list as a Nostr event */ export async function publishRelayList() { const d = debug.extend("publishRelayList"); d.log('Publishing relay list'); const ndk = getNDK(); const user = get(currentUser); if (!ndk || !user) { d.error('NDK or user not initialized'); throw new Error('NDK or user not initialized'); } try { d.log('Creating relay list event (kind 10002)'); const event = new NDKEvent(ndk, { kind: NDKKind.RelayList, content: '' }); // Add all current relays to the event const relays = Array.from(ndk.pool.relays.values()); relays.forEach((relay) => { event.tags.push(['r', relay.url]); d.log(`Added relay to tags: ${relay.url}`); }); d.log('Publishing relay list event'); await event.publish(); d.log('Relay list event published successfully'); } catch (error) { d.error('Failed to publish relay list:', error); throw error; } } // Derived store for login status export const isLoggedIn = derived(currentUser, ($currentUser) => { const loggedIn = !!$currentUser; console.log(`Login status: ${loggedIn ? 'Logged in' : 'Not logged in'}`); return loggedIn; }); // Derived store for relays list export const relays = derived(ndkInstance, ($ndk) => { const d = debug.extend('relays'); if (!$ndk) { console.log('No NDK instance, relay list is empty'); return []; } const relayList = Array.from($ndk.pool.relays.values()); d.log(`Current relay list has ${relayList.length} relays`); return relayList; }); // Derived store for relay connection status export const relayConnectionStatus = derived(relays, ($relays) => { const d = debug.extend('relayConnectionStatus'); const status = $relays.map((relay) => ({ url: relay.url, connected: relay.status >= 5, // e.g., NDKRelayStatus.CONNECTED status: relay.status })); d.log(`Relay connection status: ${status.filter(r => r.connected).length}/${status.length} connected`); return status; });