From 6b38aa365b189dc8013c7e6b9ad79ef2ed08d9b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Jan 2026 15:18:12 +0000 Subject: [PATCH] fix(nwc): improve connection reliability and add health tracking - Add connection status observable (disconnected/connecting/connected/error) - Validate wallet connection on restore using support$ observable with 10s timeout - Add notification subscription error recovery with exponential backoff (5 retries) - Add retry logic for balance refresh (3 retries with backoff) - Use library's support$ observable for wallet capabilities (cached by applesauce) - Replace manual getInfo() calls with reactive support$ subscription - Add visual connection status indicator in WalletViewer header - Add reconnect button when connection is in error state - Store network info in cached connection for display https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ --- src/components/ConnectWalletDialog.tsx | 2 + src/components/WalletViewer.tsx | 121 ++++++----- src/hooks/useWallet.ts | 59 +++++- src/services/nwc.ts | 267 +++++++++++++++++++++---- src/types/app.ts | 1 + 5 files changed, 348 insertions(+), 102 deletions(-) diff --git a/src/components/ConnectWalletDialog.tsx b/src/components/ConnectWalletDialog.tsx index 44333d5..8211637 100644 --- a/src/components/ConnectWalletDialog.tsx +++ b/src/components/ConnectWalletDialog.tsx @@ -83,6 +83,7 @@ export default function ConnectWalletDialog({ balance, info: { alias: info.alias, + network: info.network, methods: info.methods, notifications: info.notifications, }, @@ -96,6 +97,7 @@ export default function ConnectWalletDialog({ // Update info updateNWCInfo({ alias: info.alias, + network: info.network, methods: info.methods, notifications: info.notifications, }); diff --git a/src/components/WalletViewer.tsx b/src/components/WalletViewer.tsx index e610397..0393e91 100644 --- a/src/components/WalletViewer.tsx +++ b/src/components/WalletViewer.tsx @@ -76,17 +76,6 @@ interface Transaction { metadata?: Record; } -interface WalletInfo { - alias?: string; - color?: string; - pubkey?: string; - network?: string; - block_height?: number; - block_hash?: string; - methods: string[]; - notifications?: string[]; -} - interface InvoiceDetails { amount?: number; description?: string; @@ -409,16 +398,18 @@ export default function WalletViewer() { wallet, balance, isConnected, - getInfo, + connectionStatus, + lastError, + support, // Wallet capabilities from support$ observable (cached by library) refreshBalance, listTransactions, makeInvoice, payInvoice, lookupInvoice, disconnect, + reconnect, } = useWallet(); - const [walletInfo, setWalletInfo] = useState(null); const [transactions, setTransactions] = useState([]); const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); @@ -429,7 +420,6 @@ export default function WalletViewer() { const [txLoadFailed, setTxLoadFailed] = useState(false); // Use refs to track loading attempts without causing re-renders - const walletInfoLoadedRef = useRef(false); const lastConnectionStateRef = useRef(isConnected); const lastBalanceRefreshRef = useRef(0); const lastTxLoadRef = useRef(0); @@ -462,48 +452,34 @@ export default function WalletViewer() { const [showRawTransaction, setShowRawTransaction] = useState(false); const [copiedRawTx, setCopiedRawTx] = useState(false); - // Load wallet info when connected + // Reset state when connection changes useEffect(() => { // Detect connection state changes if (isConnected !== lastConnectionStateRef.current) { lastConnectionStateRef.current = isConnected; - walletInfoLoadedRef.current = false; if (isConnected) { // Reset transaction loading flags when wallet connects setTxLoadAttempted(false); setTxLoadFailed(false); setTransactions([]); - setWalletInfo(null); } else { // Clear all state when wallet disconnects setTxLoadAttempted(false); setTxLoadFailed(false); setTransactions([]); - setWalletInfo(null); setLoading(false); setLoadingMore(false); setHasMore(true); } } + }, [isConnected]); - // Load wallet info if connected and not yet loaded - if (isConnected && !walletInfoLoadedRef.current) { - walletInfoLoadedRef.current = true; - getInfo() - .then((info) => setWalletInfo(info)) - .catch((error) => { - console.error("Failed to load wallet info:", error); - toast.error("Failed to load wallet info"); - walletInfoLoadedRef.current = false; // Allow retry - }); - } - }, [isConnected, getInfo]); - - // Load transactions when wallet info is available (only once) + // Load transactions when wallet support is available (only once) + // support comes from the library's support$ observable (cached) useEffect(() => { if ( - walletInfo?.methods.includes("list_transactions") && + support?.methods?.includes("list_transactions") && !txLoadAttempted && !loading ) { @@ -527,7 +503,7 @@ export default function WalletViewer() { setLoading(false); }); } - }, [walletInfo, txLoadAttempted, loading, listTransactions]); + }, [support, txLoadAttempted, loading, listTransactions]); // Helper to reload transactions (resets flags to trigger reload) const reloadTransactions = useCallback(() => { @@ -549,7 +525,7 @@ export default function WalletViewer() { if (!generatedPaymentHash || !receiveDialogOpen) return; const checkPayment = async () => { - if (!walletInfo?.methods.includes("lookup_invoice")) return; + if (!support?.methods?.includes("lookup_invoice")) return; setCheckingPayment(true); try { @@ -574,14 +550,14 @@ export default function WalletViewer() { }, [ generatedPaymentHash, receiveDialogOpen, - walletInfo, + support, lookupInvoice, reloadTransactions, ]); const loadMoreTransactions = useCallback(async () => { if ( - !walletInfo?.methods.includes("list_transactions") || + !support?.methods?.includes("list_transactions") || !hasMore || loadingMore ) { @@ -603,7 +579,7 @@ export default function WalletViewer() { } finally { setLoadingMore(false); } - }, [walletInfo, hasMore, loadingMore, transactions.length, listTransactions]); + }, [support, hasMore, loadingMore, transactions.length, listTransactions]); async function handleRefreshBalance() { // Rate limiting: minimum 2 seconds between refreshes @@ -984,17 +960,49 @@ export default function WalletViewer() {
{/* Header */}
- {/* Left: Wallet Name + Status */} + {/* Left: Wallet Name + Connection Status */}
- {walletInfo?.alias || "Lightning Wallet"} + {state.nwcConnection?.info?.alias || "Lightning Wallet"} -
+ + +
+ + + {connectionStatus === "connected" && "Connected"} + {connectionStatus === "connecting" && "Connecting..."} + {connectionStatus === "error" && ( + Error: {lastError?.message || "Connection failed"} + )} + {connectionStatus === "disconnected" && "Disconnected"} + + + {connectionStatus === "error" && ( + + )}
{/* Right: Info Dropdown, Refresh, Disconnect */}
- {walletInfo && ( + {support && (
- {walletInfo.network && ( + {state.nwcConnection?.info?.network && (
Network - {walletInfo.network} + {state.nwcConnection.info.network}
)} @@ -1049,7 +1057,7 @@ export default function WalletViewer() {
Capabilities
- {walletInfo.methods.map((method) => ( + {support.methods?.map((method) => (
- {walletInfo.notifications && - walletInfo.notifications.length > 0 && ( + {support.notifications && + support.notifications.length > 0 && (
Notifications
- {walletInfo.notifications.map((notification) => ( + {support.notifications.map((notification) => ( {/* Send / Receive Buttons */} - {walletInfo && - (walletInfo.methods.includes("pay_invoice") || - walletInfo.methods.includes("make_invoice")) && ( + {support && + (support.methods?.includes("pay_invoice") || + support.methods?.includes("make_invoice")) && (
- {walletInfo.methods.includes("make_invoice") && ( + {support.methods?.includes("make_invoice") && ( )} - {walletInfo.methods.includes("pay_invoice") && ( + {support.methods?.includes("pay_invoice") && (
; + * } + * + * // Wallet capabilities from support$ observable (cached by library) + * const canPay = support?.methods?.includes('pay_invoice'); * * async function handlePay() { * if (!wallet) return; @@ -20,7 +28,7 @@ * ``` */ -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import { use$ } from "applesauce-react/hooks"; import { useGrimoire } from "@/core/state"; import { @@ -28,7 +36,10 @@ import { restoreWallet, clearWallet as clearWalletService, refreshBalance as refreshBalanceService, + reconnect as reconnectService, balance$, + connectionStatus$, + lastError$, } from "@/services/nwc"; import type { WalletConnect } from "applesauce-wallet-connect"; @@ -36,22 +47,37 @@ export function useWallet() { const { state } = useGrimoire(); const nwcConnection = state.nwcConnection; const [wallet, setWallet] = useState(getWallet()); + const restoreAttemptedRef = useRef(false); - // Subscribe to balance updates from observable (fully reactive!) + // Subscribe to observables (fully reactive!) 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 + const support = use$(() => wallet?.support$, [wallet]); // Initialize wallet on mount if connection exists but no wallet instance useEffect(() => { - if (nwcConnection && !wallet) { + if (nwcConnection && !wallet && !restoreAttemptedRef.current) { + restoreAttemptedRef.current = true; console.log("[useWallet] Restoring wallet from saved connection"); - const restoredWallet = restoreWallet(nwcConnection); - setWallet(restoredWallet); - // Fetch initial balance - refreshBalanceService(); + // restoreWallet is now async and validates the connection + restoreWallet(nwcConnection).then((restoredWallet) => { + setWallet(restoredWallet); + }); } }, [nwcConnection, wallet]); + // Reset restore flag when connection is cleared + useEffect(() => { + if (!nwcConnection) { + restoreAttemptedRef.current = false; + } + }, [nwcConnection]); + // Update local wallet ref when connection changes useEffect(() => { const currentWallet = getWallet(); @@ -170,6 +196,13 @@ export function useWallet() { setWallet(null); } + /** + * Attempt to reconnect the wallet after an error + */ + async function reconnect() { + await reconnectService(); + } + return { /** The wallet instance (null if not connected) */ wallet, @@ -177,11 +210,17 @@ export function useWallet() { balance, /** Whether a wallet is connected */ isConnected: !!wallet, + /** Connection status: 'disconnected' | 'connecting' | 'connected' | 'error' */ + 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 */ payInvoice, /** Generate a new invoice */ makeInvoice, - /** Get wallet information */ + /** Get wallet information - prefer using support instead */ getInfo, /** Get current balance */ getBalance, @@ -195,5 +234,7 @@ export function useWallet() { payKeysend, /** Disconnect wallet */ disconnect, + /** Attempt to reconnect after an error */ + reconnect, }; } diff --git a/src/services/nwc.ts b/src/services/nwc.ts index 8c40ba0..5a6bc0a 100644 --- a/src/services/nwc.ts +++ b/src/services/nwc.ts @@ -9,18 +9,48 @@ * - 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 */ import { WalletConnect } from "applesauce-wallet-connect"; import type { NWCConnection } from "@/types/app"; import pool from "./relay-pool"; -import { BehaviorSubject, Subscription } from "rxjs"; +import { BehaviorSubject, Subscription, firstValueFrom, timeout } from "rxjs"; // Set the pool for wallet connect to use WalletConnect.pool = pool; let walletInstance: WalletConnect | null = null; 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" + | "connecting" + | "connected" + | "error"; + +/** + * Observable for connection status + * Components can subscribe to this for real-time connection state using use$() + */ +export const connectionStatus$ = new BehaviorSubject( + "disconnected", +); + +/** + * Observable for the last connection error + * Components can use this to display error messages + */ +export const lastError$ = new BehaviorSubject(null); /** * Observable for wallet balance updates @@ -40,43 +70,104 @@ function hexToBytes(hex: string): Uint8Array { } /** - * Subscribe to wallet notifications (NIP-47 kind 23197) + * Subscribe to wallet notifications with automatic retry on error * This enables real-time balance updates when transactions occur */ -function subscribeToNotifications(wallet: WalletConnect) { +function subscribeToNotificationsWithRetry(wallet: WalletConnect) { // Clean up existing subscription if (notificationSubscription) { notificationSubscription.unsubscribe(); + notificationSubscription = null; } - console.log("[NWC] Subscribing to wallet notifications"); + let retryCount = 0; + const maxRetries = 5; + const baseDelay = 2000; // 2 seconds - // 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); + function subscribe() { + console.log( + `[NWC] Subscribing to wallet notifications (attempt ${retryCount + 1})`, + ); - // 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, + notificationSubscription = wallet.notifications$.subscribe({ + next: (notification) => { + console.log("[NWC] Notification received:", notification); + retryCount = 0; // Reset retry count on success + + // Mark as connected if we were in error state + if (connectionStatus$.value === "error") { + connectionStatus$.next("connected"); + lastError$.next(null); + } + + // When we get a notification, refresh the balance + refreshBalance(); + }, + error: (error) => { + console.error("[NWC] Notification subscription 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 + ? error + : new Error("Notification subscription failed"), ); - }); + } + }, + complete: () => { + console.log("[NWC] Notification subscription completed"); + // If subscription completes unexpectedly, try to reconnect + if (walletInstance && retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + retryCount++; + console.log( + `[NWC] Subscription completed, reconnecting in ${delay}ms`, + ); + setTimeout(subscribe, delay); + } + }, + }); + } + + 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] Notification subscription error:", error); + console.error("[NWC] Support subscription error:", error); + // Don't set error state here as notifications subscription handles recovery }, }); } @@ -86,28 +177,73 @@ function subscribeToNotifications(wallet: WalletConnect) { * Automatically subscribes to notifications for balance updates */ export function createWalletFromURI(connectionString: string): WalletConnect { + connectionStatus$.next("connecting"); + lastError$.next(null); + walletInstance = WalletConnect.fromConnectURI(connectionString); - subscribeToNotifications(walletInstance); + subscribeToSupport(walletInstance); + subscribeToNotificationsWithRetry(walletInstance); + return walletInstance; } /** * 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 */ -export function restoreWallet(connection: NWCConnection): WalletConnect { +export async function restoreWallet( + connection: NWCConnection, +): Promise { + connectionStatus$.next("connecting"); + lastError$.next(null); + walletInstance = new WalletConnect({ service: connection.service, relays: connection.relays, secret: hexToBytes(connection.secret), }); - // Set initial balance from cache + // Set initial balance from cache while we validate if (connection.balance !== undefined) { balance$.next(connection.balance); } - subscribeToNotifications(walletInstance); + // Subscribe to support$ for cached wallet capabilities + subscribeToSupport(walletInstance); + + // Validate connection by waiting for support info with timeout + try { + console.log("[NWC] Validating wallet connection..."); + await firstValueFrom( + walletInstance.support$.pipe( + timeout({ + first: 10000, // 10 second timeout for first value + with: () => { + throw new Error("Connection validation timeout"); + }, + }), + ), + ); + console.log("[NWC] Wallet connection validated"); + connectionStatus$.next("connected"); + } catch (error) { + console.error("[NWC] Wallet validation failed:", error); + connectionStatus$.next("error"); + lastError$.next( + error instanceof Error + ? error + : new Error("Connection validation failed"), + ); + // Continue anyway - notifications subscription will retry + } + + // Subscribe to notifications with retry logic + subscribeToNotificationsWithRetry(walletInstance); + + // Refresh balance from wallet (not just cache) + refreshBalance(); + return walletInstance; } @@ -126,25 +262,80 @@ export function clearWallet(): void { notificationSubscription.unsubscribe(); notificationSubscription = null; } + if (supportSubscription) { + supportSubscription.unsubscribe(); + supportSubscription = null; + } walletInstance = 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 */ export async function refreshBalance(): Promise { if (!walletInstance) return undefined; - try { - const result = await walletInstance.getBalance(); - const newBalance = result.balance; + const maxRetries = 3; + const baseDelay = 1000; - balance$.next(newBalance); - return newBalance; - } catch (error) { - console.error("[NWC] Failed to refresh balance:", error); - return undefined; + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + const result = await walletInstance.getBalance(); + const newBalance = result.balance; + + balance$.next(newBalance); + + // Mark as connected on successful balance fetch + if (connectionStatus$.value === "error") { + connectionStatus$.next("connected"); + lastError$.next(null); + } + + return newBalance; + } catch (error) { + console.error( + `[NWC] Failed to refresh balance (attempt ${attempt + 1}):`, + error, + ); + + if (attempt < maxRetries - 1) { + await new Promise((resolve) => + setTimeout(resolve, baseDelay * Math.pow(2, attempt)), + ); + } 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"), + ); + } + } + } } + + return undefined; +} + +/** + * Attempt to reconnect the wallet + * Call this when the user wants to manually retry after an error + */ +export async function reconnect(): Promise { + if (!walletInstance) return; + + connectionStatus$.next("connecting"); + lastError$.next(null); + + // Re-subscribe to support and notifications + subscribeToSupport(walletInstance); + subscribeToNotificationsWithRetry(walletInstance); + + // Try to refresh balance + await refreshBalance(); } diff --git a/src/types/app.ts b/src/types/app.ts index 5b114e5..ff449c8 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -100,6 +100,7 @@ export interface NWCConnection { /** Optional wallet info */ info?: { alias?: string; + network?: string; methods?: string[]; notifications?: string[]; };