From 8e6e989ca5c6bb3859ff2c95404a2f7f262868a9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Jan 2026 09:20:30 +0000 Subject: [PATCH] refactor: make NWC fully reactive with notifications and graceful balance handling Changes: - Remove polling mechanism in favor of pure reactive notifications$ observable - Subscribe to wallet.notifications$ for real-time balance updates - Make balance display conditional (only show if available) - Fix TypeScript errors (notification.type access, unused variable) - Remove Jotai callback mechanism for balance updates - Use use$() directly for reactive balance subscription - Update comments to reflect reactive architecture (no polling) The wallet now updates balance automatically when payments are sent/received via NIP-47 notifications, with no polling overhead. --- src/components/nostr/user-menu.tsx | 48 ++++++---- src/hooks/useWallet.ts | 33 +++---- src/services/nwc.ts | 145 ++++++++++++----------------- 3 files changed, 99 insertions(+), 127 deletions(-) diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 20f9207..635563d 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -88,7 +88,7 @@ export default function UserMenu() { const walletServiceProfile = useProfile(nwcConnection?.service); // Use wallet hook for real-time balance and methods - const { disconnect: disconnectWallet, refreshBalance } = useWallet(); + const { disconnect: disconnectWallet, refreshBalance, balance } = useWallet(); function openProfile() { if (!account?.pubkey) return; @@ -111,7 +111,7 @@ export default function UserMenu() { } function handleDisconnectWallet() { - // Disconnect from NWC service (stops polling, clears wallet instance) + // Disconnect from NWC service (stops notifications, clears wallet instance) disconnectWallet(); // Clear connection from state disconnectNWC(); @@ -123,7 +123,7 @@ export default function UserMenu() { try { await refreshBalance(); toast.success("Balance refreshed"); - } catch (error) { + } catch (_error) { toast.error("Failed to refresh balance"); } } @@ -160,22 +160,27 @@ export default function UserMenu() {
{/* Balance */} -
- Balance: -
- - {formatBalance(nwcConnection.balance)} + {(balance !== undefined || + nwcConnection.balance !== undefined) && ( +
+ + Balance: - +
+ + {formatBalance(balance ?? nwcConnection.balance)} + + +
-
+ )} {/* Wallet Name */}
@@ -279,9 +284,12 @@ export default function UserMenu() { >
- - {formatBalance(nwcConnection.balance)} - + {balance !== undefined || + nwcConnection.balance !== undefined ? ( + + {formatBalance(balance ?? nwcConnection.balance)} + + ) : null}
{getWalletName()} diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index c565568..61ac112 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -2,19 +2,20 @@ * useWallet Hook * * Provides access to the NWC wallet throughout the application. - * Handles wallet lifecycle, balance updates, and transaction methods. + * Fully reactive using observables - balance updates automatically via use$() * * @example * ```tsx * function MyComponent() { - * const { wallet, balance, payInvoice, makeInvoice, refreshBalance } = useWallet(); + * const { wallet, balance, payInvoice, makeInvoice } = useWallet(); * * async function handlePay() { * if (!wallet) return; * await payInvoice("lnbc..."); + * // Balance automatically updates via notifications! * } * - * return
Balance: {balance} sats
; + * return
Balance: {balance ? Math.floor(balance / 1000) : 0} sats
; * } * ``` */ @@ -26,18 +27,17 @@ import { getWallet, restoreWallet, clearWallet as clearWalletService, - setBalanceUpdateCallback, refreshBalance as refreshBalanceService, balance$, } from "@/services/nwc"; import type { WalletConnect } from "applesauce-wallet-connect"; export function useWallet() { - const { state, updateNWCBalance } = useGrimoire(); + const { state } = useGrimoire(); const nwcConnection = state.nwcConnection; const [wallet, setWallet] = useState(getWallet()); - // Subscribe to balance updates from the service + // Subscribe to balance updates from observable (fully reactive!) const balance = use$(balance$); // Initialize wallet on mount if connection exists but no wallet instance @@ -47,39 +47,30 @@ export function useWallet() { const restoredWallet = restoreWallet(nwcConnection); setWallet(restoredWallet); - // Refresh balance on restore + // Fetch initial balance refreshBalanceService(); } }, [nwcConnection, wallet]); - // Set up balance update callback to sync with state - useEffect(() => { - setBalanceUpdateCallback((newBalance) => { - updateNWCBalance(newBalance); - }); - - return () => { - setBalanceUpdateCallback(() => {}); - }; - }, [updateNWCBalance]); - // Update local wallet ref when connection changes useEffect(() => { const currentWallet = getWallet(); if (currentWallet !== wallet) { setWallet(currentWallet); } - }, [nwcConnection]); + }, [nwcConnection, wallet]); /** * 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); - // Refresh balance after payment + // Balance will update automatically via notifications + // But we can also refresh immediately for instant feedback await refreshBalanceService(); return result; @@ -138,7 +129,7 @@ export function useWallet() { return { /** The wallet instance (null if not connected) */ wallet, - /** Current balance in millisats (undefined if not available) */ + /** Current balance in millisats (auto-updates via observable!) */ balance, /** Whether a wallet is connected */ isConnected: !!wallet, diff --git a/src/services/nwc.ts b/src/services/nwc.ts index 40d5809..8c40ba0 100644 --- a/src/services/nwc.ts +++ b/src/services/nwc.ts @@ -7,101 +7,27 @@ * Features: * - Maintains persistent wallet connection across app lifetime * - Subscribes to NIP-47 notifications (kind 23197) for balance updates - * - Automatically updates balance when transactions occur - * - Provides hook for easy wallet access throughout the app + * - Fully reactive using RxJS observables (no polling!) + * - Components use use$() to reactively subscribe to balance changes */ import { WalletConnect } from "applesauce-wallet-connect"; import type { NWCConnection } from "@/types/app"; import pool from "./relay-pool"; -import { BehaviorSubject } from "rxjs"; +import { BehaviorSubject, Subscription } from "rxjs"; // Set the pool for wallet connect to use WalletConnect.pool = pool; let walletInstance: WalletConnect | null = null; -let notificationSubscription: any = null; +let notificationSubscription: Subscription | null = null; /** * Observable for wallet balance updates - * Components can subscribe to this for real-time balance changes + * Components can subscribe to this for real-time balance changes using use$() */ export const balance$ = new BehaviorSubject(undefined); -/** - * Callback for balance updates (set by the hook/state management) - */ -let onBalanceUpdate: ((balance: number) => void) | null = null; - -/** - * Register a callback for balance updates - */ -export function setBalanceUpdateCallback(callback: (balance: number) => void) { - onBalanceUpdate = callback; -} - -/** - * Subscribe to wallet notifications (NIP-47 kind 23197) - * This enables real-time balance updates when transactions occur - */ -function subscribeToNotifications(wallet: WalletConnect) { - // Clean up existing subscription - if (notificationSubscription) { - notificationSubscription.unsubscribe(); - } - - // Subscribe to notifications from the wallet service - // The applesauce-wallet-connect library handles this internally - // when notifications are enabled in the wallet info - console.log("[NWC] Subscribed to wallet notifications"); - - // Note: The actual notification subscription is handled by WalletConnect - // We can poll for balance updates periodically as a fallback - startBalancePolling(wallet); -} - -/** - * Poll for balance updates every 30 seconds - * This ensures we stay in sync even if notifications aren't working - */ -function startBalancePolling(wallet: WalletConnect) { - const pollInterval = setInterval(async () => { - try { - const result = await wallet.getBalance(); - const newBalance = result.balance; - - // Update observable - if (balance$.value !== newBalance) { - balance$.next(newBalance); - - // Trigger callback for state updates - if (onBalanceUpdate) { - onBalanceUpdate(newBalance); - } - - console.log("[NWC] Balance updated:", newBalance); - } - } catch (error) { - console.error("[NWC] Failed to poll balance:", error); - } - }, 30000); // Poll every 30 seconds - - // Store for cleanup - notificationSubscription = { - unsubscribe: () => clearInterval(pollInterval), - }; -} - -/** - * Creates a new WalletConnect instance from a connection string - * Automatically subscribes to notifications for balance updates - */ -export function createWalletFromURI(connectionString: string): WalletConnect { - walletInstance = WalletConnect.fromConnectURI(connectionString); - subscribeToNotifications(walletInstance); - return walletInstance; -} - /** * Helper to convert hex string to Uint8Array */ @@ -113,6 +39,58 @@ function hexToBytes(hex: string): Uint8Array { return bytes; } +/** + * Subscribe to wallet notifications (NIP-47 kind 23197) + * This enables real-time balance updates when transactions occur + */ +function subscribeToNotifications(wallet: WalletConnect) { + // Clean up existing subscription + if (notificationSubscription) { + notificationSubscription.unsubscribe(); + } + + console.log("[NWC] Subscribing to wallet notifications"); + + // Subscribe to the wallet's notifications$ observable + // This receives events like payment_received, payment_sent, etc. + notificationSubscription = wallet.notifications$.subscribe({ + next: (notification) => { + console.log("[NWC] Notification received:", notification); + + // When we get a notification, refresh the balance + // The notification types include: payment_received, payment_sent, etc. + wallet + .getBalance() + .then((result) => { + const newBalance = result.balance; + if (balance$.value !== newBalance) { + balance$.next(newBalance); + console.log("[NWC] Balance updated from notification:", newBalance); + } + }) + .catch((error) => { + console.error( + "[NWC] Failed to fetch balance after notification:", + error, + ); + }); + }, + error: (error) => { + console.error("[NWC] Notification subscription error:", error); + }, + }); +} + +/** + * Creates a new WalletConnect instance from a connection string + * Automatically subscribes to notifications for balance updates + */ +export function createWalletFromURI(connectionString: string): WalletConnect { + walletInstance = WalletConnect.fromConnectURI(connectionString); + subscribeToNotifications(walletInstance); + return walletInstance; +} + /** * Restores a wallet from saved connection data * Used on app startup to reconnect to a previously connected wallet @@ -124,7 +102,7 @@ export function restoreWallet(connection: NWCConnection): WalletConnect { secret: hexToBytes(connection.secret), }); - // Set initial balance + // Set initial balance from cache if (connection.balance !== undefined) { balance$.next(connection.balance); } @@ -150,11 +128,11 @@ export function clearWallet(): void { } walletInstance = null; balance$.next(undefined); - onBalanceUpdate = null; } /** * Manually refresh the balance from the wallet + * Useful for initial load or manual refresh button */ export async function refreshBalance(): Promise { if (!walletInstance) return undefined; @@ -164,11 +142,6 @@ export async function refreshBalance(): Promise { const newBalance = result.balance; balance$.next(newBalance); - - if (onBalanceUpdate) { - onBalanceUpdate(newBalance); - } - return newBalance; } catch (error) { console.error("[NWC] Failed to refresh balance:", error);