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();
}