diff --git a/src/components/WalletViewer.tsx b/src/components/WalletViewer.tsx index 90a651d..da5f1a9 100644 --- a/src/components/WalletViewer.tsx +++ b/src/components/WalletViewer.tsx @@ -396,12 +396,12 @@ export default function WalletViewer() { toggleWalletBalancesBlur, } = useGrimoire(); const { - wallet, balance, isConnected, connectionStatus, lastError, - support, // Wallet capabilities from support$ observable (cached by library) + support, + walletMethods, // Combined support$ + cached info fallback refreshBalance, listTransactions, makeInvoice, @@ -420,8 +420,7 @@ export default function WalletViewer() { const [txLoadAttempted, setTxLoadAttempted] = useState(false); const [txLoadFailed, setTxLoadFailed] = useState(false); - // Use refs to track loading attempts without causing re-renders - const lastConnectionStateRef = useRef(isConnected); + // Rate limiting refs const lastBalanceRefreshRef = useRef(0); const lastTxLoadRef = useRef(0); @@ -456,36 +455,27 @@ export default function WalletViewer() { // Copy NWC connection string state const [copiedNwc, setCopiedNwc] = useState(false); - // Wallet methods - use support$ observable if available, fallback to cached info - // The support$ observable waits for kind 13194 events which some wallets don't publish - // The cached info comes from getInfo() RPC call during initial connection - const walletMethods = useMemo(() => { - return support?.methods ?? state.nwcConnection?.info?.methods ?? []; - }, [support?.methods, state.nwcConnection?.info?.methods]); - - // Reset state when connection changes + // Reset transaction state when wallet disconnects useEffect(() => { - // Detect connection state changes - if (isConnected !== lastConnectionStateRef.current) { - lastConnectionStateRef.current = isConnected; - - if (isConnected) { - // Reset transaction loading flags when wallet connects - setTxLoadAttempted(false); - setTxLoadFailed(false); - setTransactions([]); - } else { - // Clear all state when wallet disconnects - setTxLoadAttempted(false); - setTxLoadFailed(false); - setTransactions([]); - setLoading(false); - setLoadingMore(false); - setHasMore(true); - } + if (!isConnected) { + setTxLoadAttempted(false); + setTxLoadFailed(false); + setTransactions([]); + setLoading(false); + setLoadingMore(false); + setHasMore(true); } }, [isConnected]); + // Reset transaction load flag when wallet connects (to trigger reload) + useEffect(() => { + if (connectionStatus === "connected") { + setTxLoadAttempted(false); + setTxLoadFailed(false); + setTransactions([]); + } + }, [connectionStatus]); + // Load transactions when wallet methods are available (only once) // walletMethods combines support$ observable with cached info fallback useEffect(() => { @@ -959,7 +949,7 @@ export default function WalletViewer() { return items; }, [transactions]); - if (!isConnected || !wallet) { + if (!isConnected) { return (
@@ -1134,7 +1124,7 @@ export default function WalletViewer() { aria-label="Copy connection string" > {copiedNwc ? ( - + ) : ( )} diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 8bc9d05..703dbe5 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -1,73 +1,67 @@ /** * useWallet Hook * - * Provides access to the NWC wallet throughout the application. - * Fully reactive using observables - balance updates automatically via use$() + * Provides reactive access to the NWC wallet throughout the application. + * All state is derived from observables - no manual synchronization needed. * * @example * ```tsx * function MyComponent() { - * const { wallet, balance, connectionStatus, support, payInvoice } = useWallet(); + * const { wallet, balance, connectionStatus, walletMethods, payInvoice } = useWallet(); * - * // Connection status: 'disconnected' | 'connecting' | 'connected' | 'error' * if (connectionStatus === 'error') { - * return
Connection error -
; + * return ; * } * - * // Wallet capabilities from support$ observable (cached by library) - * const canPay = support?.methods?.includes('pay_invoice'); - * - * async function handlePay() { - * if (!wallet) return; - * await payInvoice("lnbc..."); - * // Balance automatically updates via notifications! + * // walletMethods combines support$ with cached info for reliability + * if (walletMethods.includes('pay_invoice')) { + * return payInvoice("lnbc...")} />; * } * - * return
Balance: {balance ? Math.floor(balance / 1000) : 0} sats
; + * return
Balance: {formatSats(balance)}
; * } * ``` */ -import { useEffect, useState, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { use$ } from "applesauce-react/hooks"; import { useGrimoire } from "@/core/state"; import { - getWallet, + wallet$, restoreWallet, - clearWallet as clearWalletService, + clearWallet, refreshBalance as refreshBalanceService, reconnect as reconnectService, balance$, connectionStatus$, lastError$, } from "@/services/nwc"; -import type { WalletConnect } from "applesauce-wallet-connect"; export function useWallet() { const { state } = useGrimoire(); const nwcConnection = state.nwcConnection; - const [wallet, setWallet] = useState(getWallet()); const restoreAttemptedRef = useRef(false); - // Subscribe to observables (fully reactive!) + // All state derived from observables + const wallet = use$(wallet$); const balance = use$(balance$); const connectionStatus = use$(connectionStatus$); const lastError = use$(lastError$); - // Subscribe to wallet support$ observable for cached capabilities - // This replaces manual getInfo() calls + // Wallet support from library's support$ observable (cached) const support = use$(() => wallet?.support$, [wallet]); - // Initialize wallet on mount if connection exists but no wallet instance + // Wallet methods - combines reactive support$ with cached info fallback + // The support$ waits for kind 13194 events which some wallets don't publish + const walletMethods = useMemo(() => { + return support?.methods ?? state.nwcConnection?.info?.methods ?? []; + }, [support?.methods, state.nwcConnection?.info?.methods]); + + // Restore wallet on mount if connection exists useEffect(() => { if (nwcConnection && !wallet && !restoreAttemptedRef.current) { restoreAttemptedRef.current = true; - console.log("[useWallet] Restoring wallet from saved connection"); - - // restoreWallet is now async and validates the connection - restoreWallet(nwcConnection).then((restoredWallet) => { - setWallet(restoredWallet); - }); + restoreWallet(nwcConnection); } }, [nwcConnection, wallet]); @@ -78,33 +72,20 @@ export function useWallet() { } }, [nwcConnection]); - // Update local wallet ref when connection changes - useEffect(() => { - const currentWallet = getWallet(); - if (currentWallet !== wallet) { - setWallet(currentWallet); - } - }, [nwcConnection, wallet]); + // Derived state + const isConnected = connectionStatus !== "disconnected"; + + // ============================================================================ + // Wallet operations + // ============================================================================ - /** - * Pay a BOLT11 invoice - * Balance will auto-update via notification subscription - */ async function payInvoice(invoice: string, amount?: number) { if (!wallet) throw new Error("No wallet connected"); - const result = await wallet.payInvoice(invoice, amount); - - // Balance will update automatically via notifications - // But we can also refresh immediately for instant feedback await refreshBalanceService(); - return result; } - /** - * Generate a new invoice - */ async function makeInvoice( amount: number, options?: { @@ -114,40 +95,20 @@ export function useWallet() { }, ) { if (!wallet) throw new Error("No wallet connected"); - return await wallet.makeInvoice(amount, options); } - /** - * Get wallet info (capabilities, alias, etc.) - */ async function getInfo() { if (!wallet) throw new Error("No wallet connected"); - return await wallet.getInfo(); } - /** - * Get current balance - */ async function getBalance() { if (!wallet) throw new Error("No wallet connected"); - const result = await wallet.getBalance(); return result.balance; } - /** - * Manually refresh the balance - */ - async function refreshBalance() { - return await refreshBalanceService(); - } - - /** - * List recent transactions - * @param options - Pagination and filter options - */ async function listTransactions(options?: { from?: number; until?: number; @@ -157,84 +118,53 @@ export function useWallet() { type?: "incoming" | "outgoing"; }) { if (!wallet) throw new Error("No wallet connected"); - return await wallet.listTransactions(options); } - /** - * Look up an invoice by payment hash - * @param paymentHash - The payment hash to look up - */ async function lookupInvoice(paymentHash: string) { if (!wallet) throw new Error("No wallet connected"); - return await wallet.lookupInvoice(paymentHash); } - /** - * Pay to a node pubkey directly (keysend) - * @param pubkey - The node pubkey to pay - * @param amount - Amount in millisats - * @param preimage - Optional preimage (hex string) - */ async function payKeysend(pubkey: string, amount: number, preimage?: string) { if (!wallet) throw new Error("No wallet connected"); - const result = await wallet.payKeysend(pubkey, amount, preimage); - - // Refresh balance after payment await refreshBalanceService(); - return result; } - /** - * Disconnect the wallet - */ function disconnect() { - clearWalletService(); - setWallet(null); + clearWallet(); } - /** - * Attempt to reconnect the wallet after an error - */ async function reconnect() { await reconnectService(); } + async function refreshBalance() { + return await refreshBalanceService(); + } + return { - /** The wallet instance (null if not connected) */ + // State (all derived from observables) wallet, - /** Current balance in millisats (auto-updates via observable!) */ balance, - /** Whether a wallet is connected */ - isConnected: !!wallet, - /** Connection status: 'disconnected' | 'connecting' | 'connected' | 'error' */ + isConnected, connectionStatus, - /** The last connection error (null if no error) */ lastError, - /** Wallet support info from support$ observable (methods, notifications, etc.) - cached by library */ support, - /** Pay a BOLT11 invoice */ + walletMethods, + + // Operations payInvoice, - /** Generate a new invoice */ makeInvoice, - /** Get wallet information - prefer using support instead */ getInfo, - /** Get current balance */ getBalance, - /** Manually refresh balance */ refreshBalance, - /** List recent transactions */ listTransactions, - /** Look up an invoice by payment hash */ lookupInvoice, - /** Pay to a node pubkey directly (keysend) */ payKeysend, - /** Disconnect wallet */ disconnect, - /** Attempt to reconnect after an error */ reconnect, }; } diff --git a/src/services/nwc.ts b/src/services/nwc.ts index 5a6bc0a..230822d 100644 --- a/src/services/nwc.ts +++ b/src/services/nwc.ts @@ -4,13 +4,11 @@ * Provides a singleton WalletConnect instance for the application using * applesauce-wallet-connect for NIP-47 Lightning wallet integration. * - * Features: - * - Maintains persistent wallet connection across app lifetime - * - Subscribes to NIP-47 notifications (kind 23197) for balance updates - * - Fully reactive using RxJS observables (no polling!) - * - Components use use$() to reactively subscribe to balance changes - * - Connection health tracking with automatic recovery - * - Uses library's support$ observable for cached wallet capabilities + * Architecture: + * - All state is exposed via BehaviorSubject observables + * - Components subscribe via use$() for automatic updates + * - Notification subscription handles balance updates reactively + * - Automatic retry with exponential backoff on failures */ import { WalletConnect } from "applesauce-wallet-connect"; @@ -18,19 +16,14 @@ import type { NWCConnection } from "@/types/app"; import pool from "./relay-pool"; import { BehaviorSubject, Subscription, firstValueFrom, timeout } from "rxjs"; -// Set the pool for wallet connect to use +// Configure the pool for wallet connect WalletConnect.pool = pool; -let walletInstance: WalletConnect | null = null; +// Internal state let notificationSubscription: Subscription | null = null; -let supportSubscription: Subscription | null = null; /** * Connection status for the NWC wallet - * - disconnected: No wallet connected - * - connecting: Wallet is being restored/validated - * - connected: Wallet is connected and responding - * - error: Connection failed or lost */ export type NWCConnectionStatus = | "disconnected" @@ -38,29 +31,28 @@ export type NWCConnectionStatus = | "connected" | "error"; -/** - * Observable for connection status - * Components can subscribe to this for real-time connection state using use$() - */ +// ============================================================================ +// Observables - All state is exposed reactively +// ============================================================================ + +/** The current wallet instance (null if not connected) */ +export const wallet$ = new BehaviorSubject(null); + +/** Connection status */ export const connectionStatus$ = new BehaviorSubject( "disconnected", ); -/** - * Observable for the last connection error - * Components can use this to display error messages - */ +/** Last connection error (null if no error) */ export const lastError$ = new BehaviorSubject(null); -/** - * Observable for wallet balance updates - * Components can subscribe to this for real-time balance changes using use$() - */ +/** Current balance in millisats */ export const balance$ = new BehaviorSubject(undefined); -/** - * Helper to convert hex string to Uint8Array - */ +// ============================================================================ +// Internal helpers +// ============================================================================ + function hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { @@ -70,50 +62,45 @@ function hexToBytes(hex: string): Uint8Array { } /** - * Subscribe to wallet notifications with automatic retry on error - * This enables real-time balance updates when transactions occur + * Subscribe to wallet notifications with automatic retry on error. + * Notifications trigger balance refresh for real-time updates. */ -function subscribeToNotificationsWithRetry(wallet: WalletConnect) { +function subscribeToNotifications(wallet: WalletConnect) { // Clean up existing subscription - if (notificationSubscription) { - notificationSubscription.unsubscribe(); - notificationSubscription = null; - } + notificationSubscription?.unsubscribe(); + notificationSubscription = null; let retryCount = 0; const maxRetries = 5; - const baseDelay = 2000; // 2 seconds + const baseDelay = 2000; function subscribe() { - console.log( - `[NWC] Subscribing to wallet notifications (attempt ${retryCount + 1})`, - ); - notificationSubscription = wallet.notifications$.subscribe({ next: (notification) => { - console.log("[NWC] Notification received:", notification); - retryCount = 0; // Reset retry count on success + console.log( + "[NWC] Notification received:", + notification.notification_type, + ); + retryCount = 0; - // Mark as connected if we were in error state + // Recover from error state on successful notification if (connectionStatus$.value === "error") { connectionStatus$.next("connected"); lastError$.next(null); } - // When we get a notification, refresh the balance + // Refresh balance on any notification refreshBalance(); }, error: (error) => { - console.error("[NWC] Notification subscription error:", error); + console.error("[NWC] Notification error:", error); if (retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); retryCount++; - console.log(`[NWC] Retrying notification subscription in ${delay}ms`); connectionStatus$.next("connecting"); setTimeout(subscribe, delay); } else { - console.error("[NWC] Max notification retries reached"); connectionStatus$.next("error"); lastError$.next( error instanceof Error @@ -123,14 +110,10 @@ function subscribeToNotificationsWithRetry(wallet: WalletConnect) { } }, complete: () => { - console.log("[NWC] Notification subscription completed"); - // If subscription completes unexpectedly, try to reconnect - if (walletInstance && retryCount < maxRetries) { + // Reconnect if subscription completes unexpectedly + if (wallet$.value && retryCount < maxRetries) { const delay = baseDelay * Math.pow(2, retryCount); retryCount++; - console.log( - `[NWC] Subscription completed, reconnecting in ${delay}ms`, - ); setTimeout(subscribe, delay); } }, @@ -140,57 +123,29 @@ function subscribeToNotificationsWithRetry(wallet: WalletConnect) { subscribe(); } -/** - * Subscribe to the wallet's support$ observable for cached capabilities - * This keeps connection alive and validates the wallet is responding - */ -function subscribeToSupport(wallet: WalletConnect) { - // Clean up existing subscription - if (supportSubscription) { - supportSubscription.unsubscribe(); - supportSubscription = null; - } - - supportSubscription = wallet.support$.subscribe({ - next: (support) => { - if (support) { - console.log("[NWC] Wallet support info received:", support); - // Mark as connected when we receive support info - if ( - connectionStatus$.value === "connecting" || - connectionStatus$.value === "error" - ) { - connectionStatus$.next("connected"); - lastError$.next(null); - } - } - }, - error: (error) => { - console.error("[NWC] Support subscription error:", error); - // Don't set error state here as notifications subscription handles recovery - }, - }); -} +// ============================================================================ +// Public API +// ============================================================================ /** - * Creates a new WalletConnect instance from a connection string - * Automatically subscribes to notifications for balance updates + * Creates a new wallet connection from a NWC URI. + * Used when user connects a new wallet. */ export function createWalletFromURI(connectionString: string): WalletConnect { connectionStatus$.next("connecting"); lastError$.next(null); - walletInstance = WalletConnect.fromConnectURI(connectionString); - subscribeToSupport(walletInstance); - subscribeToNotificationsWithRetry(walletInstance); + const wallet = WalletConnect.fromConnectURI(connectionString); + wallet$.next(wallet); - return walletInstance; + subscribeToNotifications(wallet); + + return wallet; } /** - * Restores a wallet from saved connection data - * Used on app startup to reconnect to a previously connected wallet - * Validates the connection using the support$ observable + * Restores a wallet from saved connection data. + * Validates the connection before marking as connected. */ export async function restoreWallet( connection: NWCConnection, @@ -198,123 +153,98 @@ export async function restoreWallet( connectionStatus$.next("connecting"); lastError$.next(null); - walletInstance = new WalletConnect({ + const wallet = new WalletConnect({ service: connection.service, relays: connection.relays, secret: hexToBytes(connection.secret), }); - // Set initial balance from cache while we validate + wallet$.next(wallet); + + // Show cached balance immediately while validating if (connection.balance !== undefined) { balance$.next(connection.balance); } - // Subscribe to support$ for cached wallet capabilities - subscribeToSupport(walletInstance); - - // Validate connection by waiting for support info with timeout + // Validate connection by waiting for support info try { - console.log("[NWC] Validating wallet connection..."); await firstValueFrom( - walletInstance.support$.pipe( + wallet.support$.pipe( timeout({ - first: 10000, // 10 second timeout for first value + first: 10000, with: () => { - throw new Error("Connection validation timeout"); + throw new Error("Connection timeout"); }, }), ), ); - console.log("[NWC] Wallet connection validated"); connectionStatus$.next("connected"); } catch (error) { - console.error("[NWC] Wallet validation failed:", error); + console.error("[NWC] Validation failed:", error); connectionStatus$.next("error"); lastError$.next( - error instanceof Error - ? error - : new Error("Connection validation failed"), + error instanceof Error ? error : new Error("Connection failed"), ); - // Continue anyway - notifications subscription will retry + // Continue anyway - notifications will retry } - // Subscribe to notifications with retry logic - subscribeToNotificationsWithRetry(walletInstance); - - // Refresh balance from wallet (not just cache) + subscribeToNotifications(wallet); refreshBalance(); - return walletInstance; + return wallet; } /** - * Gets the current wallet instance - */ -export function getWallet(): WalletConnect | null { - return walletInstance; -} - -/** - * Clears the current wallet instance and stops notifications + * Disconnects and clears the wallet. */ export function clearWallet(): void { - if (notificationSubscription) { - notificationSubscription.unsubscribe(); - notificationSubscription = null; - } - if (supportSubscription) { - supportSubscription.unsubscribe(); - supportSubscription = null; - } - walletInstance = null; + notificationSubscription?.unsubscribe(); + notificationSubscription = null; + + wallet$.next(null); balance$.next(undefined); connectionStatus$.next("disconnected"); lastError$.next(null); } /** - * Manually refresh the balance from the wallet - * Useful for initial load or manual refresh button - * Includes retry logic for reliability + * Refreshes the balance from the wallet. + * Includes retry logic for reliability. */ export async function refreshBalance(): Promise { - if (!walletInstance) return undefined; + const wallet = wallet$.value; + if (!wallet) return undefined; const maxRetries = 3; const baseDelay = 1000; for (let attempt = 0; attempt < maxRetries; attempt++) { try { - const result = await walletInstance.getBalance(); - const newBalance = result.balance; + const result = await wallet.getBalance(); + balance$.next(result.balance); - balance$.next(newBalance); - - // Mark as connected on successful balance fetch + // Recover from error state on success if (connectionStatus$.value === "error") { connectionStatus$.next("connected"); lastError$.next(null); } - return newBalance; + return result.balance; } catch (error) { console.error( - `[NWC] Failed to refresh balance (attempt ${attempt + 1}):`, + `[NWC] Balance refresh failed (attempt ${attempt + 1}):`, error, ); if (attempt < maxRetries - 1) { - await new Promise((resolve) => - setTimeout(resolve, baseDelay * Math.pow(2, attempt)), + await new Promise((r) => + setTimeout(r, baseDelay * Math.pow(2, attempt)), + ); + } else if (connectionStatus$.value !== "connected") { + connectionStatus$.next("error"); + lastError$.next( + error instanceof Error ? error : new Error("Failed to get balance"), ); - } else { - // Only set error state on final failure if not already connected - if (connectionStatus$.value !== "connected") { - connectionStatus$.next("error"); - lastError$.next( - error instanceof Error ? error : new Error("Failed to get balance"), - ); - } } } } @@ -323,19 +253,15 @@ export async function refreshBalance(): Promise { } /** - * Attempt to reconnect the wallet - * Call this when the user wants to manually retry after an error + * Attempts to reconnect after an error. */ export async function reconnect(): Promise { - if (!walletInstance) return; + const wallet = wallet$.value; + if (!wallet) return; connectionStatus$.next("connecting"); lastError$.next(null); - // Re-subscribe to support and notifications - subscribeToSupport(walletInstance); - subscribeToNotificationsWithRetry(walletInstance); - - // Try to refresh balance + subscribeToNotifications(wallet); await refreshBalance(); }