refactor(nwc): simplify with derived state from observables

Production-ready refactor of NWC implementation:

nwc.ts:
- Add wallet$ observable for reactive wallet instance access
- Remove redundant subscribeToSupport() - only needed for validation
- Cleaner code organization with clear sections

useWallet.ts:
- All state derived from observables (no useState for wallet)
- Move walletMethods computation to hook (reusable)
- isConnected derived from connectionStatus
- Simplified from 240 to 170 lines

WalletViewer.tsx:
- Use walletMethods from hook instead of local useMemo
- Simpler connection state tracking via connectionStatus
- Remove redundant wallet variable from destructuring
- No color change on copy NWC string (per feedback)

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ
This commit is contained in:
Claude
2026-01-29 15:43:55 +00:00
parent 6abd261b75
commit 079e410ff7
3 changed files with 147 additions and 301 deletions

View File

@@ -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 (
<div className="flex h-full items-center justify-center p-8">
<Card className="max-w-md">
@@ -1134,7 +1124,7 @@ export default function WalletViewer() {
aria-label="Copy connection string"
>
{copiedNwc ? (
<CopyCheck className="size-3 text-green-500" />
<CopyCheck className="size-3" />
) : (
<Copy className="size-3" />
)}

View File

@@ -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 <div>Connection error - <button onClick={reconnect}>Retry</button></div>;
* return <ErrorState onRetry={reconnect} />;
* }
*
* // 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 <PayButton onClick={() => payInvoice("lnbc...")} />;
* }
*
* return <div>Balance: {balance ? Math.floor(balance / 1000) : 0} sats</div>;
* return <div>Balance: {formatSats(balance)}</div>;
* }
* ```
*/
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<WalletConnect | null>(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,
};
}

View File

@@ -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<WalletConnect | null>(null);
/** Connection status */
export const connectionStatus$ = new BehaviorSubject<NWCConnectionStatus>(
"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<Error | null>(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<number | undefined>(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<number | undefined> {
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<number | undefined> {
}
/**
* 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<void> {
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();
}