Files
grimoire/src/hooks/useWallet.ts
Alejandro f3cc7779e3 feat: make NWC connections more robust and wallet state reactive (#227)
* 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

* feat(wallet): add copy NWC connection string button to header

Adds a copy button (Copy/Check icons) in the wallet header before the
refresh button that copies the NWC connection string to clipboard for
easy sharing or backup.

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* fix(wallet): use CopyCheck icon and fix transaction loading

- Change Check to CopyCheck icon for copy NWC string button
- Add walletMethods computed value that combines support$ observable
  with cached info fallback from initial connection
- Fix transaction history not loading because support$ waits for
  kind 13194 events which many NWC wallets don't publish
- The cached info from getInfo() RPC call is now used as fallback

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* 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

* refactor(wallet): use useCopy hook for clipboard operations

Replace manual clipboard state management with useCopy hook:
- copyInvoice/invoiceCopied for generated invoice
- copyRawTx/rawTxCopied for transaction JSON
- copyNwc/nwcCopied for NWC connection string

Benefits:
- Cleaner code (removed manual setTimeout calls)
- Automatic timeout cleanup on unmount
- Consistent copy behavior across all clipboard operations

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* feat(wallet): implement lazy-loaded transactions observable

- Add shared wallet types (Transaction, TransactionsState) in src/types/wallet.ts
- Add transactionsState$ observable to NWC service for shared tx state
- Implement loadTransactions, loadMoreTransactions, and retryLoadTransactions
- Auto-refresh transactions on payment notifications
- Simplify WalletViewer to use observable state instead of local state
- Remove manual transaction loading logic from component

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* fix(wallet): update balance observable on initial connect

- Call refreshBalance() in createWalletFromURI to fetch initial balance
- Update balance$ directly when ConnectWalletDialog gets balance
- Fixes issue where WalletViewer showed "-" after connecting while
  user menu showed correct balance (different data sources)

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* refactor(wallet): use single data source for balance across UI

Remove fallback to nwcConnection.balance in user-menu - now both
WalletViewer and user-menu use balance$ observable as the single
source of truth for wallet balance.

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* fix(wallet): address code review issues and simplify user menu

- Fix memory leak: track retry timeout and clear on disconnect
- Add explicit WalletSupport type for support observable
- Add comments explaining balance refresh error handling behavior
- Add comment about restoreWallet not being awaited intentionally
- User menu now uses connectionStatus observable (shows connecting/error states)
- Remove wallet name display from user menu (simplifies UI)
- Remove unused walletServiceProfile hook and getWalletName function

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

* refactor(wallet): extract WalletConnectionStatus component

- Create reusable WalletConnectionStatus component for connection indicator
- Remove rounded borders from indicator (now square)
- Export getConnectionStatusColor helper for custom usage
- Use component in both user-menu and WalletViewer
- Supports size (sm/md), showLabel, and className props

https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-01-29 18:02:40 +01:00

198 lines
5.4 KiB
TypeScript

/**
* useWallet Hook
*
* 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, walletMethods, payInvoice } = useWallet();
*
* if (connectionStatus === 'error') {
* return <ErrorState onRetry={reconnect} />;
* }
*
* // walletMethods combines support$ with cached info for reliability
* if (walletMethods.includes('pay_invoice')) {
* return <PayButton onClick={() => payInvoice("lnbc...")} />;
* }
*
* return <div>Balance: {formatSats(balance)}</div>;
* }
* ```
*/
import { useEffect, useMemo, useRef } from "react";
import { use$ } from "applesauce-react/hooks";
import { useGrimoire } from "@/core/state";
import type { WalletSupport } from "applesauce-wallet-connect/helpers";
import {
wallet$,
restoreWallet,
clearWallet,
refreshBalance as refreshBalanceService,
reconnect as reconnectService,
balance$,
connectionStatus$,
lastError$,
transactionsState$,
loadTransactions as loadTransactionsService,
loadMoreTransactions as loadMoreTransactionsService,
retryLoadTransactions as retryLoadTransactionsService,
} from "@/services/nwc";
export function useWallet() {
const { state } = useGrimoire();
const nwcConnection = state.nwcConnection;
const restoreAttemptedRef = useRef(false);
// All state derived from observables
const wallet = use$(wallet$);
const balance = use$(balance$);
const connectionStatus = use$(connectionStatus$);
const lastError = use$(lastError$);
const transactionsState = use$(transactionsState$);
// Wallet support from library's support$ observable (cached by library for 60s)
const support: WalletSupport | null | undefined = use$(
() => wallet?.support$,
[wallet],
);
// 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
// Note: Not awaited intentionally - wallet is available synchronously from wallet$
// before validation completes. Any async errors are handled within restoreWallet.
useEffect(() => {
if (nwcConnection && !wallet && !restoreAttemptedRef.current) {
restoreAttemptedRef.current = true;
restoreWallet(nwcConnection);
}
}, [nwcConnection, wallet]);
// Reset restore flag when connection is cleared
useEffect(() => {
if (!nwcConnection) {
restoreAttemptedRef.current = false;
}
}, [nwcConnection]);
// Derived state
const isConnected = connectionStatus !== "disconnected";
// ============================================================================
// Wallet operations
// ============================================================================
async function payInvoice(invoice: string, amount?: number) {
if (!wallet) throw new Error("No wallet connected");
const result = await wallet.payInvoice(invoice, amount);
await refreshBalanceService();
return result;
}
async function makeInvoice(
amount: number,
options?: {
description?: string;
description_hash?: string;
expiry?: number;
},
) {
if (!wallet) throw new Error("No wallet connected");
return await wallet.makeInvoice(amount, options);
}
async function getInfo() {
if (!wallet) throw new Error("No wallet connected");
return await wallet.getInfo();
}
async function getBalance() {
if (!wallet) throw new Error("No wallet connected");
const result = await wallet.getBalance();
return result.balance;
}
async function listTransactions(options?: {
from?: number;
until?: number;
limit?: number;
offset?: number;
unpaid?: boolean;
type?: "incoming" | "outgoing";
}) {
if (!wallet) throw new Error("No wallet connected");
return await wallet.listTransactions(options);
}
async function lookupInvoice(paymentHash: string) {
if (!wallet) throw new Error("No wallet connected");
return await wallet.lookupInvoice(paymentHash);
}
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);
await refreshBalanceService();
return result;
}
function disconnect() {
clearWallet();
}
async function reconnect() {
await reconnectService();
}
async function refreshBalance() {
return await refreshBalanceService();
}
async function loadTransactions() {
await loadTransactionsService();
}
async function loadMoreTransactions() {
await loadMoreTransactionsService();
}
async function retryLoadTransactions() {
await retryLoadTransactionsService();
}
return {
// State (all derived from observables)
wallet,
balance,
isConnected,
connectionStatus,
lastError,
support,
walletMethods,
transactionsState,
// Operations
payInvoice,
makeInvoice,
getInfo,
getBalance,
refreshBalance,
listTransactions,
lookupInvoice,
payKeysend,
disconnect,
reconnect,
loadTransactions,
loadMoreTransactions,
retryLoadTransactions,
};
}