diff --git a/src/components/ConnectWalletDialog.tsx b/src/components/ConnectWalletDialog.tsx index 44333d5..7a5bc04 100644 --- a/src/components/ConnectWalletDialog.tsx +++ b/src/components/ConnectWalletDialog.tsx @@ -11,7 +11,7 @@ import { import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { useGrimoire } from "@/core/state"; -import { createWalletFromURI } from "@/services/nwc"; +import { createWalletFromURI, balance$ } from "@/services/nwc"; interface ConnectWalletDialogProps { open: boolean; @@ -66,6 +66,8 @@ export default function ConnectWalletDialog({ try { const balanceResult = await wallet.getBalance(); balance = balanceResult.balance; + // Update the observable immediately so WalletViewer shows correct balance + balance$.next(balance); } catch (err) { console.warn("[NWC] Failed to get balance:", err); // Balance is optional, continue anyway @@ -83,6 +85,7 @@ export default function ConnectWalletDialog({ balance, info: { alias: info.alias, + network: info.network, methods: info.methods, notifications: info.notifications, }, @@ -96,6 +99,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/WalletConnectionStatus.tsx b/src/components/WalletConnectionStatus.tsx new file mode 100644 index 0000000..e5eaf92 --- /dev/null +++ b/src/components/WalletConnectionStatus.tsx @@ -0,0 +1,65 @@ +/** + * WalletConnectionStatus Component + * + * Displays a visual indicator for wallet connection status. + * Shows different colors and animations based on status: + * - connected: green + * - connecting: yellow (pulsing) + * - error: red + * - disconnected: gray + */ + +import type { NWCConnectionStatus } from "@/services/nwc"; + +interface WalletConnectionStatusProps { + status: NWCConnectionStatus; + /** Size of the indicator */ + size?: "sm" | "md"; + /** Whether to show the status label */ + showLabel?: boolean; + /** Additional class names */ + className?: string; +} + +const sizeClasses = { + sm: "size-1.5", + md: "size-2", +}; + +/** + * Get the color class for a connection status + */ +export function getConnectionStatusColor(status: NWCConnectionStatus): string { + switch (status) { + case "connected": + return "bg-green-500"; + case "connecting": + return "bg-yellow-500 animate-pulse"; + case "error": + return "bg-red-500"; + default: + return "bg-gray-500"; + } +} + +export function WalletConnectionStatus({ + status, + size = "sm", + showLabel = false, + className = "", +}: WalletConnectionStatusProps) { + const colorClass = getConnectionStatusColor(status); + + if (!showLabel) { + return ( + + ); + } + + return ( +
+ + {status} +
+ ); +} diff --git a/src/components/WalletViewer.tsx b/src/components/WalletViewer.tsx index e610397..0ac56b7 100644 --- a/src/components/WalletViewer.tsx +++ b/src/components/WalletViewer.tsx @@ -5,7 +5,7 @@ * Layout: Header → Big centered balance → Send/Receive buttons → Transaction list */ -import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { useState, useEffect, useMemo, useRef } from "react"; import { toast } from "sonner"; import { Wallet, @@ -14,6 +14,7 @@ import { Download, Info, Copy, + CopyCheck, Check, ArrowUpRight, ArrowDownLeft, @@ -26,6 +27,7 @@ import { } from "lucide-react"; import { Virtuoso } from "react-virtuoso"; import { useWallet } from "@/hooks/useWallet"; +import { useCopy } from "@/hooks/useCopy"; import { useGrimoire } from "@/core/state"; import { decode as decodeBolt11 } from "light-bolt11-decoder"; import { Button } from "@/components/ui/button"; @@ -60,32 +62,8 @@ import { KindRenderer } from "./nostr/kinds"; import { RichText } from "./nostr/RichText"; import { UserName } from "./nostr/UserName"; import { CodeCopyButton } from "./CodeCopyButton"; - -interface Transaction { - type: "incoming" | "outgoing"; - invoice?: string; - description?: string; - description_hash?: string; - preimage?: string; - payment_hash?: string; - amount: number; - fees_paid?: number; - created_at: number; - expires_at?: number; - settled_at?: number; - metadata?: Record; -} - -interface WalletInfo { - alias?: string; - color?: string; - pubkey?: string; - network?: string; - block_height?: number; - block_hash?: string; - methods: string[]; - notifications?: string[]; -} +import { WalletConnectionStatus } from "./WalletConnectionStatus"; +import type { Transaction } from "@/types/wallet"; interface InvoiceDetails { amount?: number; @@ -94,7 +72,6 @@ interface InvoiceDetails { expiry?: number; } -const BATCH_SIZE = 20; const PAYMENT_CHECK_INTERVAL = 5000; // Check every 5 seconds /** @@ -406,33 +383,30 @@ export default function WalletViewer() { toggleWalletBalancesBlur, } = useGrimoire(); const { - wallet, balance, isConnected, - getInfo, + connectionStatus, + lastError, + support, + walletMethods, // Combined support$ + cached info fallback + transactionsState, refreshBalance, - listTransactions, makeInvoice, payInvoice, lookupInvoice, disconnect, + reconnect, + loadTransactions, + loadMoreTransactions, + retryLoadTransactions, } = useWallet(); - const [walletInfo, setWalletInfo] = useState(null); - const [transactions, setTransactions] = useState([]); - const [loading, setLoading] = useState(false); - const [loadingMore, setLoadingMore] = useState(false); - const [hasMore, setHasMore] = useState(true); + const [refreshingBalance, setRefreshingBalance] = useState(false); const [connectDialogOpen, setConnectDialogOpen] = useState(false); const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false); - const [txLoadAttempted, setTxLoadAttempted] = useState(false); - const [txLoadFailed, setTxLoadFailed] = useState(false); - // Use refs to track loading attempts without causing re-renders - const walletInfoLoadedRef = useRef(false); - const lastConnectionStateRef = useRef(isConnected); + // Rate limiting ref const lastBalanceRefreshRef = useRef(0); - const lastTxLoadRef = useRef(0); // Send dialog state const [sendDialogOpen, setSendDialogOpen] = useState(false); @@ -452,7 +426,6 @@ export default function WalletViewer() { const [generatedPaymentHash, setGeneratedPaymentHash] = useState(""); const [invoiceQR, setInvoiceQR] = useState(""); const [generating, setGenerating] = useState(false); - const [copied, setCopied] = useState(false); const [checkingPayment, setCheckingPayment] = useState(false); // Transaction detail dialog state @@ -460,107 +433,37 @@ export default function WalletViewer() { useState(null); const [detailDialogOpen, setDetailDialogOpen] = useState(false); const [showRawTransaction, setShowRawTransaction] = useState(false); - const [copiedRawTx, setCopiedRawTx] = useState(false); - // Load wallet info when connected - useEffect(() => { - // Detect connection state changes - if (isConnected !== lastConnectionStateRef.current) { - lastConnectionStateRef.current = isConnected; - walletInfoLoadedRef.current = false; + // Copy hooks for clipboard operations + const { copy: copyInvoice, copied: invoiceCopied } = useCopy(2000); + const { copy: copyRawTx, copied: rawTxCopied } = useCopy(2000); + const { copy: copyNwc, copied: nwcCopied } = useCopy(2000); - 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); - } - } - - // 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) + // Trigger lazy load of transactions when wallet supports it useEffect(() => { if ( - walletInfo?.methods.includes("list_transactions") && - !txLoadAttempted && - !loading + walletMethods.includes("list_transactions") && + !transactionsState.initialized ) { - setLoading(true); - setTxLoadAttempted(true); - listTransactions({ - limit: BATCH_SIZE, - offset: 0, - }) - .then((result) => { - const txs = result.transactions || []; - setTransactions(txs); - setHasMore(txs.length === BATCH_SIZE); - setTxLoadFailed(false); - }) - .catch((error) => { - console.error("Failed to load transactions:", error); - setTxLoadFailed(true); - }) - .finally(() => { - setLoading(false); - }); + loadTransactions(); } - }, [walletInfo, txLoadAttempted, loading, listTransactions]); - - // Helper to reload transactions (resets flags to trigger reload) - const reloadTransactions = useCallback(() => { - // Rate limiting: minimum 5 seconds between transaction reloads - const now = Date.now(); - const timeSinceLastLoad = now - lastTxLoadRef.current; - if (timeSinceLastLoad < 5000) { - const waitTime = Math.ceil((5000 - timeSinceLastLoad) / 1000); - toast.warning(`Please wait ${waitTime}s before reloading transactions`); - return; - } - - lastTxLoadRef.current = now; - setTxLoadAttempted(false); - setTxLoadFailed(false); - }, []); + }, [walletMethods, transactionsState.initialized, loadTransactions]); + // Poll for payment status when waiting for invoice to be paid useEffect(() => { if (!generatedPaymentHash || !receiveDialogOpen) return; const checkPayment = async () => { - if (!walletInfo?.methods.includes("lookup_invoice")) return; + if (!walletMethods.includes("lookup_invoice")) return; setCheckingPayment(true); try { const result = await lookupInvoice(generatedPaymentHash); - // If invoice is settled, close dialog and refresh + // If invoice is settled, close dialog (notifications will refresh transactions) if (result.settled_at) { toast.success("Payment received!"); setReceiveDialogOpen(false); resetReceiveDialog(); - // Reload transactions - reloadTransactions(); } } catch { // Ignore errors, will retry @@ -571,39 +474,7 @@ export default function WalletViewer() { const intervalId = setInterval(checkPayment, PAYMENT_CHECK_INTERVAL); return () => clearInterval(intervalId); - }, [ - generatedPaymentHash, - receiveDialogOpen, - walletInfo, - lookupInvoice, - reloadTransactions, - ]); - - const loadMoreTransactions = useCallback(async () => { - if ( - !walletInfo?.methods.includes("list_transactions") || - !hasMore || - loadingMore - ) { - return; - } - - setLoadingMore(true); - try { - const result = await listTransactions({ - limit: BATCH_SIZE, - offset: transactions.length, - }); - const newTxs = result.transactions || []; - setTransactions((prev) => [...prev, ...newTxs]); - setHasMore(newTxs.length === BATCH_SIZE); - } catch (error) { - console.error("Failed to load more transactions:", error); - toast.error("Failed to load more transactions"); - } finally { - setLoadingMore(false); - } - }, [walletInfo, hasMore, loadingMore, transactions.length, listTransactions]); + }, [generatedPaymentHash, receiveDialogOpen, walletMethods, lookupInvoice]); async function handleRefreshBalance() { // Rate limiting: minimum 2 seconds between refreshes @@ -616,7 +487,7 @@ export default function WalletViewer() { } lastBalanceRefreshRef.current = now; - setLoading(true); + setRefreshingBalance(true); try { await refreshBalance(); toast.success("Balance refreshed"); @@ -624,10 +495,24 @@ export default function WalletViewer() { console.error("Failed to refresh balance:", error); toast.error("Failed to refresh balance"); } finally { - setLoading(false); + setRefreshingBalance(false); } } + function handleCopyNwcString() { + if (!state.nwcConnection) return; + + const { service, relays, secret, lud16 } = state.nwcConnection; + const params = new URLSearchParams(); + relays.forEach((relay) => params.append("relay", relay)); + params.append("secret", secret); + if (lud16) params.append("lud16", lud16); + + const nwcString = `nostr+walletconnect://${service}?${params.toString()}`; + copyNwc(nwcString); + toast.success("Connection string copied"); + } + async function handleConfirmSend() { if (!sendInvoice.trim()) { toast.error("Please enter an invoice or Lightning address"); @@ -804,8 +689,7 @@ export default function WalletViewer() { toast.success("Payment sent successfully"); resetSendDialog(); setSendDialogOpen(false); - // Reload transactions - reloadTransactions(); + // Notifications will automatically refresh transactions } catch (error) { console.error("Payment failed:", error); toast.error(error instanceof Error ? error.message : "Payment failed"); @@ -868,16 +752,9 @@ export default function WalletViewer() { } } - async function handleCopyInvoice() { - try { - await navigator.clipboard.writeText(generatedInvoice); - setCopied(true); - toast.success("Invoice copied to clipboard"); - setTimeout(() => setCopied(false), 2000); - } catch (error) { - console.error("Failed to copy invoice:", error); - toast.error("Failed to copy to clipboard"); - } + function handleCopyInvoice() { + copyInvoice(generatedInvoice); + toast.success("Invoice copied to clipboard"); } function resetReceiveDialog() { @@ -886,7 +763,6 @@ export default function WalletViewer() { setInvoiceQR(""); setReceiveAmount(""); setReceiveDescription(""); - setCopied(false); } function handleDisconnect() { @@ -912,6 +788,13 @@ export default function WalletViewer() { return new Date(timestamp * 1000).toLocaleString(); } + // Derive values from transactionsState for convenience + const transactions = transactionsState.items; + const txLoading = transactionsState.loading; + const txLoadingMore = transactionsState.loadingMore; + const txHasMore = transactionsState.hasMore; + const txError = transactionsState.error; + // Process transactions to include day markers const transactionsWithMarkers = useMemo(() => { if (!transactions || transactions.length === 0) return []; @@ -948,7 +831,7 @@ export default function WalletViewer() { return items; }, [transactions]); - if (!isConnected || !wallet) { + if (!isConnected) { return (
@@ -984,17 +867,39 @@ 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 +954,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) => ( )} + + + + + Copy Connection String + + @@ -1133,42 +1055,38 @@ export default function WalletViewer() {
{/* Send / Receive Buttons */} - {walletInfo && - (walletInfo.methods.includes("pay_invoice") || - walletInfo.methods.includes("make_invoice")) && ( -
-
- {walletInfo.methods.includes("make_invoice") && ( - - )} - {walletInfo.methods.includes("pay_invoice") && ( - - )} -
+ {(walletMethods.includes("pay_invoice") || + walletMethods.includes("make_invoice")) && ( +
+
+ {walletMethods.includes("make_invoice") && ( + + )} + {walletMethods.includes("pay_invoice") && ( + + )}
- )} +
+ )} {/* Transaction History */}
- {walletInfo?.methods.includes("list_transactions") ? ( - loading ? ( + {walletMethods.includes("list_transactions") ? ( + txLoading ? (
- ) : txLoadFailed ? ( + ) : txError ? (

Failed to load transaction history @@ -1176,7 +1094,7 @@ export default function WalletViewer() { -

- {/* Connection Status */}
Status: -
- - - {wallet ? "Connected" : "Disconnected"} - -
+
{/* Lightning Address */} @@ -402,25 +360,15 @@ export default function UserMenu() { >
- {balance !== undefined || - nwcConnection.balance !== undefined ? ( + {balance !== undefined && ( {state.walletBalancesBlurred ? "✦✦✦✦" - : formatBalance(balance ?? nwcConnection.balance)} + : formatBalance(balance)} - ) : null} -
-
- - - {getWalletName()} - + )}
+ ) : ( ; * } * - * return
Balance: {balance ? Math.floor(balance / 1000) : 0} sats
; + * // walletMethods combines support$ with cached info for reliability + * if (walletMethods.includes('pay_invoice')) { + * return payInvoice("lnbc...")} />; + * } + * + * return
Balance: {formatSats(balance)}
; * } * ``` */ -import { useEffect, useState } from "react"; +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 { - getWallet, + wallet$, restoreWallet, - clearWallet as clearWalletService, + clearWallet, refreshBalance as refreshBalanceService, + reconnect as reconnectService, balance$, + connectionStatus$, + lastError$, + transactionsState$, + loadTransactions as loadTransactionsService, + loadMoreTransactions as loadMoreTransactionsService, + retryLoadTransactions as retryLoadTransactionsService, } 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 balance updates from observable (fully reactive!) + // 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$); - // Initialize wallet on mount if connection exists but no wallet instance + // 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) { - console.log("[useWallet] Restoring wallet from saved connection"); - const restoredWallet = restoreWallet(nwcConnection); - setWallet(restoredWallet); - - // Fetch initial balance - refreshBalanceService(); + if (nwcConnection && !wallet && !restoreAttemptedRef.current) { + restoreAttemptedRef.current = true; + restoreWallet(nwcConnection); } }, [nwcConnection, wallet]); - // Update local wallet ref when connection changes + // Reset restore flag when connection is cleared useEffect(() => { - const currentWallet = getWallet(); - if (currentWallet !== wallet) { - setWallet(currentWallet); + if (!nwcConnection) { + restoreAttemptedRef.current = false; } - }, [nwcConnection, wallet]); + }, [nwcConnection]); + + // 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?: { @@ -88,40 +106,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; @@ -131,69 +129,69 @@ 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(); + } + + 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 { - /** 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, - /** Pay a BOLT11 invoice */ + isConnected, + connectionStatus, + lastError, + support, + walletMethods, + transactionsState, + + // Operations payInvoice, - /** Generate a new invoice */ makeInvoice, - /** Get wallet information */ 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, + reconnect, + loadTransactions, + loadMoreTransactions, + retryLoadTransactions, }; } diff --git a/src/services/nwc.ts b/src/services/nwc.ts index 8c40ba0..386a552 100644 --- a/src/services/nwc.ts +++ b/src/services/nwc.ts @@ -4,33 +4,65 @@ * 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 + * 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"; import type { NWCConnection } from "@/types/app"; +import { + type TransactionsState, + INITIAL_TRANSACTIONS_STATE, +} from "@/types/wallet"; 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 +// Configure the pool for wallet connect WalletConnect.pool = pool; -let walletInstance: WalletConnect | null = null; +// Internal state let notificationSubscription: Subscription | null = null; +let notificationRetryTimeout: ReturnType | null = null; /** - * Observable for wallet balance updates - * Components can subscribe to this for real-time balance changes using use$() + * Connection status for the NWC wallet */ +export type NWCConnectionStatus = + | "disconnected" + | "connecting" + | "connected" + | "error"; + +// ============================================================================ +// 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", +); + +/** Last connection error (null if no error) */ +export const lastError$ = new BehaviorSubject(null); + +/** Current balance in millisats */ export const balance$ = new BehaviorSubject(undefined); -/** - * Helper to convert hex string to Uint8Array - */ +/** Transaction list state (lazy loaded) */ +export const transactionsState$ = new BehaviorSubject( + INITIAL_TRANSACTIONS_STATE, +); + +// ============================================================================ +// Internal helpers +// ============================================================================ + function hexToBytes(hex: string): Uint8Array { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { @@ -40,111 +72,383 @@ function hexToBytes(hex: string): Uint8Array { } /** - * Subscribe to wallet notifications (NIP-47 kind 23197) - * 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 subscribeToNotifications(wallet: WalletConnect) { - // Clean up existing subscription - if (notificationSubscription) { - notificationSubscription.unsubscribe(); + // Clean up existing subscription and pending retry + notificationSubscription?.unsubscribe(); + notificationSubscription = null; + if (notificationRetryTimeout) { + clearTimeout(notificationRetryTimeout); + notificationRetryTimeout = null; } - console.log("[NWC] Subscribing to wallet notifications"); + let retryCount = 0; + const maxRetries = 5; + const baseDelay = 2000; - // 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() { + notificationSubscription = wallet.notifications$.subscribe({ + next: (notification) => { + console.log( + "[NWC] Notification received:", + notification.notification_type, + ); + retryCount = 0; - // 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, + // Recover from error state on successful notification + if (connectionStatus$.value === "error") { + connectionStatus$.next("connected"); + lastError$.next(null); + } + + // Refresh balance and transactions on any notification + refreshBalance(); + refreshTransactions(); + }, + error: (error) => { + console.error("[NWC] Notification error:", error); + + if (retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + retryCount++; + connectionStatus$.next("connecting"); + notificationRetryTimeout = setTimeout(subscribe, delay); + } else { + connectionStatus$.next("error"); + lastError$.next( + error instanceof Error + ? error + : new Error("Notification subscription failed"), ); - }); - }, - error: (error) => { - console.error("[NWC] Notification subscription error:", error); - }, - }); + } + }, + complete: () => { + // Reconnect if subscription completes unexpectedly + if (wallet$.value && retryCount < maxRetries) { + const delay = baseDelay * Math.pow(2, retryCount); + retryCount++; + notificationRetryTimeout = setTimeout(subscribe, delay); + } + }, + }); + } + + subscribe(); } +// ============================================================================ +// 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 { - walletInstance = WalletConnect.fromConnectURI(connectionString); - subscribeToNotifications(walletInstance); - return walletInstance; + connectionStatus$.next("connecting"); + lastError$.next(null); + + const wallet = WalletConnect.fromConnectURI(connectionString); + wallet$.next(wallet); + + subscribeToNotifications(wallet); + refreshBalance(); // Fetch initial balance + + return wallet; } /** - * Restores a wallet from saved connection data - * Used on app startup to reconnect to a previously connected wallet + * Restores a wallet from saved connection data. + * Validates the connection before marking as connected. */ -export function restoreWallet(connection: NWCConnection): WalletConnect { - walletInstance = new WalletConnect({ +export async function restoreWallet( + connection: NWCConnection, +): Promise { + connectionStatus$.next("connecting"); + lastError$.next(null); + + const wallet = new WalletConnect({ service: connection.service, relays: connection.relays, secret: hexToBytes(connection.secret), }); - // Set initial balance from cache + wallet$.next(wallet); + + // Show cached balance immediately while validating if (connection.balance !== undefined) { balance$.next(connection.balance); } - subscribeToNotifications(walletInstance); - return walletInstance; + // Validate connection by waiting for support info + try { + await firstValueFrom( + wallet.support$.pipe( + timeout({ + first: 10000, + with: () => { + throw new Error("Connection timeout"); + }, + }), + ), + ); + connectionStatus$.next("connected"); + } catch (error) { + console.error("[NWC] Validation failed:", error); + connectionStatus$.next("error"); + lastError$.next( + error instanceof Error ? error : new Error("Connection failed"), + ); + // Continue anyway - notifications will retry + } + + subscribeToNotifications(wallet); + refreshBalance(); + + 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; + // Clean up subscription and pending retry + notificationSubscription?.unsubscribe(); + notificationSubscription = null; + if (notificationRetryTimeout) { + clearTimeout(notificationRetryTimeout); + notificationRetryTimeout = null; } - walletInstance = null; + + wallet$.next(null); balance$.next(undefined); + connectionStatus$.next("disconnected"); + lastError$.next(null); + resetTransactions(); } /** - * Manually refresh the balance from the wallet - * Useful for initial load or manual refresh button + * Refreshes the balance from the wallet. + * Includes retry logic with exponential backoff for reliability. + * + * Note: If we're already connected and a balance fetch fails after retries, + * we don't set error state. This prevents UI flapping - the notification + * subscription is the primary health indicator. A transient balance fetch + * failure shouldn't mark an otherwise working connection as errored. */ 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 wallet.getBalance(); + balance$.next(result.balance); + + // Recover from error state on success + if (connectionStatus$.value === "error") { + connectionStatus$.next("connected"); + lastError$.next(null); + } + + return result.balance; + } catch (error) { + console.error( + `[NWC] Balance refresh failed (attempt ${attempt + 1}):`, + error, + ); + + if (attempt < maxRetries - 1) { + await new Promise((r) => + setTimeout(r, baseDelay * Math.pow(2, attempt)), + ); + } else if (connectionStatus$.value !== "connected") { + // Only set error state if not already connected (e.g., during initial validation) + connectionStatus$.next("error"); + lastError$.next( + error instanceof Error ? error : new Error("Failed to get balance"), + ); + } + } + } + + return undefined; +} + +/** + * Attempts to reconnect after an error. + */ +export async function reconnect(): Promise { + const wallet = wallet$.value; + if (!wallet) return; + + connectionStatus$.next("connecting"); + lastError$.next(null); + + subscribeToNotifications(wallet); + await refreshBalance(); +} + +// ============================================================================ +// Transaction loading (lazy, paginated) +// ============================================================================ + +const TRANSACTIONS_PAGE_SIZE = 20; + +/** + * Loads the initial batch of transactions. + * Only loads if not already initialized (lazy loading). + */ +export async function loadTransactions(): Promise { + const wallet = wallet$.value; + if (!wallet) return; + + const current = transactionsState$.value; + + // Skip if already loading or initialized + if (current.loading || current.initialized) return; + + transactionsState$.next({ + ...current, + loading: true, + error: null, + }); try { - const result = await walletInstance.getBalance(); - const newBalance = result.balance; + const result = await wallet.listTransactions({ + limit: TRANSACTIONS_PAGE_SIZE, + }); - balance$.next(newBalance); - return newBalance; + transactionsState$.next({ + items: result.transactions, + loading: false, + loadingMore: false, + hasMore: result.transactions.length >= TRANSACTIONS_PAGE_SIZE, + error: null, + initialized: true, + }); } catch (error) { - console.error("[NWC] Failed to refresh balance:", error); - return undefined; + console.error("[NWC] Failed to load transactions:", error); + transactionsState$.next({ + ...transactionsState$.value, + loading: false, + error: + error instanceof Error + ? error + : new Error("Failed to load transactions"), + initialized: true, + }); } } + +/** + * Loads more transactions (pagination). + */ +export async function loadMoreTransactions(): Promise { + const wallet = wallet$.value; + if (!wallet) return; + + const current = transactionsState$.value; + + // Skip if already loading or no more to load + if (current.loading || current.loadingMore || !current.hasMore) return; + + transactionsState$.next({ + ...current, + loadingMore: true, + }); + + try { + // Get the oldest transaction timestamp for pagination + const oldestTx = current.items[current.items.length - 1]; + const until = oldestTx?.created_at; + + const result = await wallet.listTransactions({ + limit: TRANSACTIONS_PAGE_SIZE, + until, + }); + + // Filter out any duplicates (in case of overlapping timestamps) + const existingHashes = new Set(current.items.map((tx) => tx.payment_hash)); + const newTransactions = result.transactions.filter( + (tx) => !existingHashes.has(tx.payment_hash), + ); + + transactionsState$.next({ + ...current, + items: [...current.items, ...newTransactions], + loadingMore: false, + hasMore: result.transactions.length >= TRANSACTIONS_PAGE_SIZE, + }); + } catch (error) { + console.error("[NWC] Failed to load more transactions:", error); + transactionsState$.next({ + ...current, + loadingMore: false, + error: + error instanceof Error + ? error + : new Error("Failed to load more transactions"), + }); + } +} + +/** + * Refreshes the transaction list (prepends new transactions). + * Called automatically on payment notifications. + */ +export async function refreshTransactions(): Promise { + const wallet = wallet$.value; + if (!wallet) return; + + const current = transactionsState$.value; + + // Only refresh if already initialized + if (!current.initialized) return; + + try { + // Get the newest transaction timestamp + const newestTx = current.items[0]; + const from = newestTx?.created_at ? newestTx.created_at + 1 : undefined; + + const result = await wallet.listTransactions({ + limit: TRANSACTIONS_PAGE_SIZE, + from, + }); + + // Filter out duplicates and prepend new transactions + const existingHashes = new Set(current.items.map((tx) => tx.payment_hash)); + const newTransactions = result.transactions.filter( + (tx) => !existingHashes.has(tx.payment_hash), + ); + + if (newTransactions.length > 0) { + transactionsState$.next({ + ...current, + items: [...newTransactions, ...current.items], + }); + } + } catch (error) { + console.error("[NWC] Failed to refresh transactions:", error); + } +} + +/** + * Resets transaction state (called on wallet clear). + */ +function resetTransactions(): void { + transactionsState$.next(INITIAL_TRANSACTIONS_STATE); +} + +/** + * Force reload transactions (used for retry after error). + */ +export async function retryLoadTransactions(): Promise { + resetTransactions(); + await loadTransactions(); +} 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[]; }; diff --git a/src/types/wallet.ts b/src/types/wallet.ts new file mode 100644 index 0000000..78132fe --- /dev/null +++ b/src/types/wallet.ts @@ -0,0 +1,51 @@ +/** + * Wallet-related type definitions for NWC (NIP-47) + */ + +/** + * A Lightning/Bitcoin transaction from NWC list_transactions + */ +export interface Transaction { + type: "incoming" | "outgoing"; + invoice?: string; + description?: string; + description_hash?: string; + preimage?: string; + payment_hash?: string; + amount: number; + fees_paid?: number; + created_at: number; + expires_at?: number; + settled_at?: number; + metadata?: Record; +} + +/** + * State for the transactions observable + */ +export interface TransactionsState { + /** The list of transactions */ + items: Transaction[]; + /** Whether we're loading the initial batch */ + loading: boolean; + /** Whether we're loading more (pagination) */ + loadingMore: boolean; + /** Whether there are more transactions to load */ + hasMore: boolean; + /** Error from last load attempt */ + error: Error | null; + /** Whether initial load has been triggered */ + initialized: boolean; +} + +/** + * Initial state for transactions + */ +export const INITIAL_TRANSACTIONS_STATE: TransactionsState = { + items: [], + loading: false, + loadingMore: false, + hasMore: true, + error: null, + initialized: false, +};