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
This commit is contained in:
Claude
2026-01-29 15:18:12 +00:00
parent 9b883f1173
commit 6b38aa365b
5 changed files with 348 additions and 102 deletions

View File

@@ -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,
});

View File

@@ -76,17 +76,6 @@ interface Transaction {
metadata?: Record<string, any>;
}
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<WalletInfo | null>(null);
const [transactions, setTransactions] = useState<Transaction[]>([]);
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() {
<div className="h-full w-full flex flex-col bg-background text-foreground">
{/* Header */}
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between">
{/* Left: Wallet Name + Status */}
{/* Left: Wallet Name + Connection Status */}
<div className="flex items-center gap-2">
<span className="font-semibold">
{walletInfo?.alias || "Lightning Wallet"}
{state.nwcConnection?.info?.alias || "Lightning Wallet"}
</span>
<div className="size-1.5 rounded-full bg-green-500" />
<Tooltip>
<TooltipTrigger asChild>
<div
className={`size-1.5 rounded-full ${
connectionStatus === "connected"
? "bg-green-500"
: connectionStatus === "connecting"
? "bg-yellow-500 animate-pulse"
: connectionStatus === "error"
? "bg-red-500"
: "bg-gray-500"
}`}
/>
</TooltipTrigger>
<TooltipContent>
{connectionStatus === "connected" && "Connected"}
{connectionStatus === "connecting" && "Connecting..."}
{connectionStatus === "error" && (
<span>Error: {lastError?.message || "Connection failed"}</span>
)}
{connectionStatus === "disconnected" && "Disconnected"}
</TooltipContent>
</Tooltip>
{connectionStatus === "error" && (
<Button
variant="ghost"
size="sm"
className="h-5 px-2 text-xs"
onClick={reconnect}
>
Retry
</Button>
)}
</div>
{/* Right: Info Dropdown, Refresh, Disconnect */}
<div className="flex items-center gap-3">
{walletInfo && (
{support && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
@@ -1011,11 +1019,11 @@ export default function WalletViewer() {
<div className="text-xs font-semibold">
Wallet Information
</div>
{walletInfo.network && (
{state.nwcConnection?.info?.network && (
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">Network</span>
<span className="font-mono capitalize">
{walletInfo.network}
{state.nwcConnection.info.network}
</span>
</div>
)}
@@ -1049,7 +1057,7 @@ export default function WalletViewer() {
<div className="space-y-2">
<div className="text-xs font-semibold">Capabilities</div>
<div className="flex flex-wrap gap-1">
{walletInfo.methods.map((method) => (
{support.methods?.map((method) => (
<span
key={method}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-mono"
@@ -1060,14 +1068,14 @@ export default function WalletViewer() {
</div>
</div>
{walletInfo.notifications &&
walletInfo.notifications.length > 0 && (
{support.notifications &&
support.notifications.length > 0 && (
<div className="space-y-2">
<div className="text-xs font-semibold">
Notifications
</div>
<div className="flex flex-wrap gap-1">
{walletInfo.notifications.map((notification) => (
{support.notifications.map((notification) => (
<span
key={notification}
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-mono"
@@ -1133,12 +1141,12 @@ export default function WalletViewer() {
</div>
{/* 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")) && (
<div className="px-4 pb-3">
<div className="max-w-md mx-auto grid grid-cols-2 gap-3">
{walletInfo.methods.includes("make_invoice") && (
{support.methods?.includes("make_invoice") && (
<Button
onClick={() => setReceiveDialogOpen(true)}
variant="outline"
@@ -1147,7 +1155,7 @@ export default function WalletViewer() {
Receive
</Button>
)}
{walletInfo.methods.includes("pay_invoice") && (
{support.methods?.includes("pay_invoice") && (
<Button
onClick={() => setSendDialogOpen(true)}
variant="default"
@@ -1163,7 +1171,7 @@ export default function WalletViewer() {
{/* Transaction History */}
<div className="flex-1 overflow-hidden flex justify-center">
<div className="w-full max-w-md">
{walletInfo?.methods.includes("list_transactions") ? (
{support?.methods?.includes("list_transactions") ? (
loading ? (
<div className="flex h-full items-center justify-center">
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
@@ -1387,7 +1395,10 @@ export default function WalletViewer() {
{txid}
</p>
<a
href={getMempoolUrl(txid, walletInfo?.network)}
href={getMempoolUrl(
txid,
state.nwcConnection?.info?.network,
)}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:text-primary/80 transition-colors flex-shrink-0"

View File

@@ -7,7 +7,15 @@
* @example
* ```tsx
* function MyComponent() {
* const { wallet, balance, payInvoice, makeInvoice } = useWallet();
* const { wallet, balance, connectionStatus, support, payInvoice } = useWallet();
*
* // Connection status: 'disconnected' | 'connecting' | 'connected' | 'error'
* if (connectionStatus === 'error') {
* return <div>Connection error - <button onClick={reconnect}>Retry</button></div>;
* }
*
* // 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<WalletConnect | null>(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,
};
}

View File

@@ -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<NWCConnectionStatus>(
"disconnected",
);
/**
* Observable for the last connection error
* Components can use this to display error messages
*/
export const lastError$ = new BehaviorSubject<Error | null>(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<WalletConnect> {
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<number | undefined> {
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<void> {
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();
}

View File

@@ -100,6 +100,7 @@ export interface NWCConnection {
/** Optional wallet info */
info?: {
alias?: string;
network?: string;
methods?: string[];
notifications?: string[];
};