From 3408872811b4653e6dfba111d46e11e4bcc16a51 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Sun, 18 Jan 2026 20:05:19 +0100 Subject: [PATCH] feat: add comprehensive NWC wallet viewer with dynamic UI (#135) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add comprehensive NWC wallet viewer with dynamic UI Implements a full-featured Lightning wallet interface using Nostr Wallet Connect (NWC/NIP-47) with method-based UI that adapts to wallet capabilities. **New Features:** - WalletViewer component with tabbed interface (Overview, Send, Receive, Transactions) - Real-time balance display with manual refresh - Send Lightning payments via BOLT11 invoices - Generate invoices with QR codes for receiving payments - Transaction history viewer (when supported by wallet) - Wallet info and capabilities display - Enhanced useWallet hook with additional NWC methods **Enhanced Methods:** - listTransactions() - View recent payment history - lookupInvoice() - Check invoice status by payment hash - payKeysend() - Direct keysend payments to node pubkeys **UI Features:** - Dynamic tabs based on wallet capabilities - QR code generation for invoices - Copy-to-clipboard for invoices - Error handling with user-friendly messages - Loading states for async operations - Empty states for no wallet connection **Command:** - New `wallet` command to open the wallet viewer **Technical Details:** - Integrates with existing NWC service singleton - Uses reactive balance$ observable for auto-updates - Proper TypeScript types aligned with applesauce-wallet-connect - Follows Grimoire patterns for window system integration - Lazy-loaded component for optimal bundle size All tests passing. Build verified. * refactor: redesign wallet UI to single-view layout with virtualized transactions Converts the tabbed wallet interface to a conventional single-view layout with improved UX and performance optimizations. **Layout Changes:** - Removed tabs in favor of single-page layout - Balance header at top with wallet name and refresh button - Side-by-side Send/Receive cards for quick access - Transaction history below with virtualized scrolling - Disconnect button at bottom of page **New Features:** - Connect Wallet button when no wallet is connected (opens dialog in-app) - Wallet capabilities shown in tooltip on info icon - Virtualized transaction list using react-virtuoso - Batched transaction loading (20 per batch) - Automatic "load more" when scrolling to bottom - Loading states for initial load and pagination - "No more transactions" message when exhausted **Performance Improvements:** - Virtualized list rendering for smooth scrolling with many transactions - Only renders visible transactions in viewport - Lazy loads additional batches on demand - Reduced initial load to 20 transactions instead of 50 **UX Improvements:** - More conventional wallet UI pattern - Send/Receive always visible (no tab switching) - QR code and invoice appear inline when generated - Info icon with tooltip for capabilities (cleaner than full card) - Disconnect option always accessible at bottom **Technical Details:** - Fixed transaction loading race condition with separate useEffect - Proper dependency tracking for loadMoreTransactions callback - Footer component in Virtuoso for loading/end states - Responsive grid layout for Send/Receive cards All tests passing. Build verified. * refactor: redesign wallet UI with ReqViewer-style header and dialogs Implements a cleaner, more consistent wallet UI matching Grimoire patterns. **Header Changes:** - ReqViewer-style compact header (border-b, px-4, py-2, font-mono, text-xs) - Left side: Wallet name · Balance with info tooltip - Right side: Icon-only action buttons (Receive, Send, Refresh) - All actions use tooltips for labels - Consistent spacing and typography with rest of app **Send/Receive as Dialogs:** - Removed always-visible Send/Receive cards - Converted to modal dialogs opened from header icons - Send dialog: Invoice input + optional amount override - Receive dialog: Two-step flow (generate → show QR + invoice) - "Generate Another" button to create multiple invoices without closing - Cleaner main view focused on transaction list **Transaction List Cleanup:** - Removed Card wrapper and extra padding - Direct virtualized list fills viewport - Removed "Transaction History" heading (redundant) - Added flex-shrink-0 to prevent transaction items from shrinking - Improved truncation handling for long descriptions - Hover effect for better interactivity - Footer messages use consistent border-b styling **Disconnect Button:** - Changed to destructive variant (red appearance) - Moved to footer with border-t - Full width for emphasis - Clearer visual indication of destructive action **UX Improvements:** - More screen space for transactions (primary content) - Consistent icon sizing (size-3 for header actions, size-4 for tx icons) - Better visual hierarchy with header actions - Dialog pattern matches other Grimoire components - Receive dialog stays open to show generated invoice **Technical Details:** - Proper dialog state management - Invoice QR with max-height scrolling for long invoices - Reset form state when closing dialogs - Maintained all existing functionality - Optimized for virtualized scrolling performance All tests passing. Build verified. * feat: final wallet UI improvements with prominent balance and enhanced UX Redesigns wallet viewer with cleaner layout and improved user experience. **Layout Changes:** - Big centered balance display (4xl font, prominent) - Large Send/Receive buttons below balance (side by side) - Single-line transaction items with better spacing - Info dropdown next to refresh button in header **Transaction List:** - Single-line compact design (description + time + amount) - No +/- signs on amounts (cleaner look) - Generic "Payment"/"Received" labels when description missing - Time displayed in compact format (HH:MM) - Day markers between days (Today/Yesterday/Jan 15) - Virtualized scrolling with batched loading **Info Dropdown:** - Wallet capabilities shown in dropdown (Info icon + ChevronDown) - Network information - Methods displayed as compact badges - Notifications support **User Menu Integration:** - Wallet option always visible (regardless of account status) - Clicking wallet opens wallet window (not info dialog) - Balance shown inline when connected - "Connect Wallet" option when not connected **Dialog Improvements:** - Send dialog with confirmation step - Receive dialog with payment detection - Auto-close on payment received - QR code with loading overlay during payment check **Visual Hierarchy:** - Header: Wallet name (left) | Info dropdown + Refresh (right) - Big centered balance with "sats" label - Prominent action buttons (Send default, Receive outline) - Clean transaction list with hover states - Destructive disconnect button in footer All tests passing ✅ Build verified ✅ * fix: replace AlertDialog with Dialog for disconnect confirmation - AlertDialog component doesn't exist in UI library - Use regular Dialog with custom footer buttons instead - All 929 tests passing, build successful * refine: wallet UI improvements based on feedback - Remove "sats" text from balance display - Swap send/receive button positions (receive left, send right) - Remove top border from transaction list - Remove timestamps from transaction list items - Add relay link to wallet info dropdown with external link icon - Change disconnect button to destructive color (always red) - Fix imports and remove unused formatTime function * feat: enhance send/receive flows with invoice parsing and auto-confirm Send flow improvements: - Parse BOLT11 invoices using light-bolt11-decoder - Auto-proceed to confirm step when valid invoice is entered - Show parsed amount and description in confirmation dialog - Validate invoice before allowing confirmation Receive flow improvements: - Fix invoice overflow with proper truncate display - Use nested div structure for single-line truncation All changes preserve type safety with proper Section type guards * feat: add Lightning address support and refine auto-confirm behavior Send flow enhancements: - Only auto-proceed to confirm if invoice has an amount (not for zero-amount invoices) - Add Lightning address (LNURL-pay) support with automatic resolution - Fetch invoice from Lightning address with amount validation - Show "Resolving..." loading state when processing Lightning addresses - Update UI labels and placeholders to indicate Lightning address support - Require amount field for Lightning address payments Lightning address flow: 1. Detect @ symbol in input (and not starting with "ln") 2. Validate amount is provided 3. Fetch LNURL-pay endpoint from .well-known/lnurlp/{username} 4. Check min/max sendable amounts 5. Request invoice from callback with specified amount 6. Parse and confirm invoice details Error handling: - Invalid Lightning address format - Failed to fetch Lightning address - Amount out of range (too small/too large) - Failed to generate invoice from callback * fix: UI improvements for wallet viewer and mobile receive flow Confirmation dialog improvements: - Remove yellow warning styles (border, background, icon) - Fix amount calculation: show proper sats conversion with Math.floor - Clean layout with key-value pairs instead of cluttered text - Show either invoice amount OR override amount (not both) Mobile receive flow improvements: - Large prominent "Copy Invoice" button (h-12, full width) - Invoice box now tappable to copy - Better touch targets for mobile users - Clearer label: "Invoice (tap to view)" Connection status: - Show green dot + "Connected" next to wallet name in header - Clear visual feedback that wallet is online Auto-launch wallet: - After successfully connecting wallet, automatically open wallet window - Added onConnected callback to ConnectWalletDialog - Wire up callback in user menu to call openWallet Code cleanup: - Remove unused AlertTriangle import * fix: correct amount handling and mobile invoice display Amount input fixes: - Changed from millisats to sats for user input - Updated label: "Amount (sats, optional)" - Fixed Lightning address resolution: no division, amount is already in sats - Fixed payInvoice: multiply by 1000 to convert sats to millisats for NWC - Fixed confirmation dialog: removed incorrect division by 1000 Before: User enters 1000 (meant as sats) → system treats as 1000000 millisats → sends 1000 sats After: User enters 1000 sats → system converts to 1000000 millisats → sends 1000 sats ✓ Invoice parsing was correct - already converting millisats to sats. The bug was in the confirmation display and amount submission. Mobile receive invoice fix: - Removed nested div with truncate (was causing overflow) - Changed to break-all + line-clamp-2 - Invoice now wraps properly on mobile (2 lines max) - Still tappable to copy * fix: convert sats to millisats when generating invoices Critical bug fix - we were generating nearly amountless invoices. Before: - User enters 1000 sats in UI - We passed 1000 to makeInvoice (thinking it's millisats) - Generated invoice for 1000 millisats = 1 sat After: - User enters 1000 sats in UI - We multiply by 1000 → 1,000,000 millisats - Generated invoice for 1,000,000 millisats = 1000 sats ✓ This matches the send flow where we also convert sats to millisats before passing to NWC protocol. * refactor: production-readiness improvements for WalletViewer Critical fixes: - Wrap loadWalletInfo and loadInitialTransactions in useCallback to prevent infinite loops - Fix dependency arrays in useEffect hooks - Fix early return bug in handleConfirmSend that left sending state stuck - Add async/await error handling to handleCopyInvoice - Change throw new Error instead of early return in Lightning address resolution Performance optimizations: - Removed duplicate function definitions (moved to useCallback hooks) - Proper memoization of wallet loading functions Code quality: - All functions properly handle errors with try/catch - Loading states properly reset in finally blocks - No memory leaks from useEffect dependencies * fix: prevent infinite transaction loading loop in WalletViewer Previously, the transaction list would try to load constantly and fail repeatedly due to a circular dependency in the useEffect hooks. The listTransactions function from useWallet wasn't wrapped in useCallback, causing loadInitialTransactions to be recreated on every render, which triggered the useEffect infinitely. Changes: - Add txLoadAttempted and txLoadFailed flags to prevent repeated attempts - Only attempt to load transactions once on wallet connection - Fail silently on initial load (no toast spam) - Show retry button when transaction loading fails - Reset flags when wallet connects/disconnects or after successful payments - Make transaction list truly optional - wallet still works if loading fails This ensures a better UX when wallets don't support list_transactions or when the method fails for any reason. * fix: remove circular dependency in loadInitialTransactions Removed txLoadFailed from the dependency array of loadInitialTransactions callback, which was causing a circular dependency: - loadInitialTransactions depended on txLoadFailed - Function sets txLoadFailed, triggering recreation - New function reference triggers useEffect again - Infinite loop The txLoadAttempted flag in the useEffect is sufficient to prevent repeated loads. No need to check txLoadFailed inside the callback. * fix: resolve React error #185 by removing callbacks from useEffect dependencies React error #185 occurs when state updates happen during render due to unstable function references in useEffect dependencies. The root cause was that functions from useWallet (getInfo, listTransactions) aren't memoized, so they create new references on every render, causing the useEffects that depend on them to run repeatedly. Changes: - Removed loadWalletInfo and loadInitialTransactions callbacks - Use refs (walletInfoLoadedRef, lastConnectionStateRef) to track state - Call async functions directly in useEffect without depending on them - Created reloadTransactions() helper that just resets flags - Simplified all reload logic to use the helper This ensures: - No circular dependencies in useEffect - Functions only run once when conditions are met - No state updates during render - Clean, predictable loading behavior * fix: properly clear wallet state on disconnect and update copy Changes: - Clear all wallet state when disconnecting (transactions, walletInfo, loading flags) - Previously only cleared state on connect, leaving stale data visible - Remove "Mutiny" mention from connection dialog copy - Update to generic "NWC wallet provider" text Now when you disconnect the wallet with the window open, it properly clears all data and returns to the "No Wallet Connected" state. * fix: prevent wallet from auto-reconnecting after disconnect The bug was in the useWallet hook which automatically restores the wallet if nwcConnection exists in state but the wallet instance is null. When disconnecting, it only cleared the wallet instance but left nwcConnection in state, causing an immediate reconnection. Fix: - Call disconnectNWCFromState() to clear nwcConnection from Grimoire state - Then call disconnect() to clear the wallet service - This prevents the auto-restore logic from triggering Now when you disconnect the wallet, it stays disconnected until you manually reconnect. * security: add critical production-ready security fixes Invoice Validation & Expiry Checks: - Validate BOLT11 invoice format (must start with 'ln') - Check invoice expiry before displaying/processing - Validate amount is reasonable (< 21M BTC) - Surface parse errors to user with toast notifications - Prevent processing of expired invoices Lightning Address Security: - Enforce HTTPS-only for LNURL-pay requests - Add 5-second timeout to all HTTP requests - Validate callback URLs use HTTPS - Proper AbortController cleanup on timeout - Better error messages for network failures Rate Limiting: - Balance refresh: minimum 2 seconds between calls - Transaction reload: minimum 5 seconds between reloads - User-friendly warning messages with countdown - Prevents spam to wallet service providers Storage Security Warning: - Add prominent security notice in ConnectWalletDialog - Warn users about browser storage implications - Advise to only connect on trusted devices Capability Detection: - Hide Send button if wallet doesn't support pay_invoice - Hide Receive button if wallet doesn't support make_invoice - Dynamic button rendering based on wallet capabilities - Prevents errors from unsupported operations Error Handling: - WindowErrorBoundary already wraps all windows (verified) - Proper error propagation with user-friendly messages - No silent failures on critical operations These changes significantly improve security and production-readiness without breaking existing functionality. --------- Co-authored-by: Claude --- src/components/ConnectWalletDialog.tsx | 25 +- src/components/WalletViewer.tsx | 1418 ++++++++++++++++++++++++ src/components/WindowRenderer.tsx | 4 + src/components/nostr/user-menu.tsx | 163 ++- src/hooks/useWallet.ts | 50 + src/types/app.ts | 1 + src/types/man.ts | 12 + 7 files changed, 1583 insertions(+), 90 deletions(-) create mode 100644 src/components/WalletViewer.tsx diff --git a/src/components/ConnectWalletDialog.tsx b/src/components/ConnectWalletDialog.tsx index 7f05fb5..44333d5 100644 --- a/src/components/ConnectWalletDialog.tsx +++ b/src/components/ConnectWalletDialog.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; import { toast } from "sonner"; -import { Loader2, Wallet, AlertCircle } from "lucide-react"; +import { Loader2, Wallet, AlertCircle, AlertTriangle } from "lucide-react"; import { Dialog, DialogContent, @@ -16,11 +16,13 @@ import { createWalletFromURI } from "@/services/nwc"; interface ConnectWalletDialogProps { open: boolean; onOpenChange: (open: boolean) => void; + onConnected?: () => void; } export default function ConnectWalletDialog({ open, onOpenChange, + onConnected, }: ConnectWalletDialogProps) { const [connectionString, setConnectionString] = useState(""); const [loading, setLoading] = useState(false); @@ -103,6 +105,9 @@ export default function ConnectWalletDialog({ // Close dialog onOpenChange(false); + + // Call onConnected callback + onConnected?.(); } catch (err) { console.error("Wallet connection error:", err); setError(err instanceof Error ? err.message : "Failed to connect wallet"); @@ -124,10 +129,24 @@ export default function ConnectWalletDialog({

- Enter your wallet connection string. You can get this from your - wallet provider (Alby, Mutiny, etc.) + Enter your wallet connection string. You can get this from your NWC + wallet provider.

+ {/* Security warning */} +
+ +
+

+ Security Notice +

+

+ Your wallet connection will be stored in browser storage. Only + connect on trusted devices. +

+
+
+ {error && (
diff --git a/src/components/WalletViewer.tsx b/src/components/WalletViewer.tsx new file mode 100644 index 0000000..60c2064 --- /dev/null +++ b/src/components/WalletViewer.tsx @@ -0,0 +1,1418 @@ +/** + * WalletViewer Component + * + * Displays NWC wallet information and provides UI for wallet operations. + * Layout: Header → Big centered balance → Send/Receive buttons → Transaction list + */ + +import { useState, useEffect, useCallback, useMemo, useRef } from "react"; +import { toast } from "sonner"; +import { + Wallet, + RefreshCw, + Send, + Download, + Info, + Copy, + Check, + ArrowUpRight, + ArrowDownLeft, + LogOut, + ChevronDown, + ExternalLink, +} from "lucide-react"; +import { Virtuoso } from "react-virtuoso"; +import { useWallet } from "@/hooks/useWallet"; +import { useGrimoire } from "@/core/state"; +import { decode as decodeBolt11 } from "light-bolt11-decoder"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Label } from "@/components/ui/label"; +import QRCode from "qrcode"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "@/components/ui/tooltip"; +import ConnectWalletDialog from "./ConnectWalletDialog"; + +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[]; +} + +interface InvoiceDetails { + amount?: number; + description?: string; + timestamp?: number; + expiry?: number; +} + +const BATCH_SIZE = 20; +const PAYMENT_CHECK_INTERVAL = 5000; // Check every 5 seconds + +/** + * Helper: Format timestamp as a readable day marker + */ +function formatDayMarker(timestamp: number): string { + const date = new Date(timestamp * 1000); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + // Reset time parts for comparison + const dateOnly = new Date( + date.getFullYear(), + date.getMonth(), + date.getDate(), + ); + const todayOnly = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate(), + ); + const yesterdayOnly = new Date( + yesterday.getFullYear(), + yesterday.getMonth(), + yesterday.getDate(), + ); + + if (dateOnly.getTime() === todayOnly.getTime()) { + return "Today"; + } else if (dateOnly.getTime() === yesterdayOnly.getTime()) { + return "Yesterday"; + } else { + // Format as "Jan 15" (short month, no year, respects locale) + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); + } +} + +/** + * Helper: Check if two timestamps are on different days + */ +function isDifferentDay(timestamp1: number, timestamp2: number): boolean { + const date1 = new Date(timestamp1 * 1000); + const date2 = new Date(timestamp2 * 1000); + + return ( + date1.getFullYear() !== date2.getFullYear() || + date1.getMonth() !== date2.getMonth() || + date1.getDate() !== date2.getDate() + ); +} + +/** + * Parse a BOLT11 invoice to extract details with security validations + */ +function parseInvoice(invoice: string): InvoiceDetails | null { + try { + // Validate format + if (!invoice.toLowerCase().startsWith("ln")) { + throw new Error("Invalid invoice format"); + } + + const decoded = decodeBolt11(invoice); + + // Extract amount (in millisats) + const amountSection = decoded.sections.find((s) => s.name === "amount"); + const amount = + amountSection && "value" in amountSection + ? Number(amountSection.value) / 1000 // Convert to sats + : undefined; + + // Validate amount is reasonable (< 21M BTC in sats = 2.1 quadrillion msats) + if (amount && amount > 2100000000000000) { + throw new Error("Amount exceeds maximum possible value"); + } + + // Extract description + const descSection = decoded.sections.find((s) => s.name === "description"); + const description = + descSection && "value" in descSection + ? String(descSection.value) + : undefined; + + // Extract timestamp + const timestampSection = decoded.sections.find( + (s) => s.name === "timestamp", + ); + const timestamp = + timestampSection && "value" in timestampSection + ? Number(timestampSection.value) + : undefined; + + // Extract expiry + const expiry = decoded.expiry; + + // Check if invoice is expired + if (timestamp && expiry) { + const expiresAt = timestamp + expiry; + const nowSeconds = Math.floor(Date.now() / 1000); + if (expiresAt < nowSeconds) { + throw new Error("Invoice has expired"); + } + } + + return { + amount, + description, + timestamp, + expiry, + }; + } catch (error) { + console.error("Failed to parse invoice:", error); + const message = + error instanceof Error ? error.message : "Invalid invoice format"; + toast.error(`Invalid invoice: ${message}`); + return null; + } +} + +export default function WalletViewer() { + const { state, disconnectNWC: disconnectNWCFromState } = useGrimoire(); + const { + wallet, + balance, + isConnected, + getInfo, + refreshBalance, + listTransactions, + makeInvoice, + payInvoice, + lookupInvoice, + disconnect, + } = 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 [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); + const lastBalanceRefreshRef = useRef(0); + const lastTxLoadRef = useRef(0); + + // Send dialog state + const [sendDialogOpen, setSendDialogOpen] = useState(false); + const [sendInvoice, setSendInvoice] = useState(""); + const [sendAmount, setSendAmount] = useState(""); + const [sendStep, setSendStep] = useState<"input" | "confirm">("input"); + const [invoiceDetails, setInvoiceDetails] = useState( + null, + ); + const [sending, setSending] = useState(false); + + // Receive dialog state + const [receiveDialogOpen, setReceiveDialogOpen] = useState(false); + const [receiveAmount, setReceiveAmount] = useState(""); + const [receiveDescription, setReceiveDescription] = useState(""); + const [generatedInvoice, setGeneratedInvoice] = useState(""); + 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 + const [selectedTransaction, setSelectedTransaction] = + useState(null); + const [detailDialogOpen, setDetailDialogOpen] = useState(false); + + // Load wallet info when connected + useEffect(() => { + // Detect connection state changes + if (isConnected !== lastConnectionStateRef.current) { + lastConnectionStateRef.current = isConnected; + walletInfoLoadedRef.current = false; + + if (isConnected) { + // Reset transaction loading flags when wallet connects + setTxLoadAttempted(false); + setTxLoadFailed(false); + setTransactions([]); + setWalletInfo(null); + } else { + // Clear all state when wallet disconnects + setTxLoadAttempted(false); + setTxLoadFailed(false); + setTransactions([]); + setWalletInfo(null); + setLoading(false); + setLoadingMore(false); + setHasMore(true); + } + } + + // 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) + useEffect(() => { + if ( + walletInfo?.methods.includes("list_transactions") && + !txLoadAttempted && + !loading + ) { + 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); + }); + } + }, [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); + }, []); + + useEffect(() => { + if (!generatedPaymentHash || !receiveDialogOpen) return; + + const checkPayment = async () => { + if (!walletInfo?.methods.includes("lookup_invoice")) return; + + setCheckingPayment(true); + try { + const result = await lookupInvoice(generatedPaymentHash); + // If invoice is settled, close dialog and refresh + if (result.settled_at) { + toast.success("Payment received!"); + setReceiveDialogOpen(false); + resetReceiveDialog(); + // Reload transactions + reloadTransactions(); + } + } catch (error) { + // Ignore errors, will retry + } finally { + setCheckingPayment(false); + } + }; + + 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]); + + async function handleRefreshBalance() { + // Rate limiting: minimum 2 seconds between refreshes + const now = Date.now(); + const timeSinceLastRefresh = now - lastBalanceRefreshRef.current; + if (timeSinceLastRefresh < 2000) { + const waitTime = Math.ceil((2000 - timeSinceLastRefresh) / 1000); + toast.warning(`Please wait ${waitTime}s before refreshing again`); + return; + } + + lastBalanceRefreshRef.current = now; + setLoading(true); + try { + await refreshBalance(); + toast.success("Balance refreshed"); + } catch (error) { + console.error("Failed to refresh balance:", error); + toast.error("Failed to refresh balance"); + } finally { + setLoading(false); + } + } + + async function handleConfirmSend() { + if (!sendInvoice.trim()) { + toast.error("Please enter an invoice or Lightning address"); + return; + } + + const input = sendInvoice.trim(); + + // Check if it's a Lightning address + if (input.includes("@") && !input.toLowerCase().startsWith("ln")) { + // Lightning address - requires amount + if (!sendAmount || parseInt(sendAmount) <= 0) { + toast.error("Please enter an amount for Lightning address payments"); + return; + } + + setSending(true); + try { + const amountSats = parseInt(sendAmount); // Amount is in sats + const invoice = await resolveLightningAddress(input, amountSats); + + // Update the invoice field with the resolved invoice + setSendInvoice(invoice); + + // Parse the resolved invoice + const details = parseInvoice(invoice); + if (!details) { + throw new Error("Failed to parse resolved invoice"); + } + + setInvoiceDetails(details); + setSendStep("confirm"); + } catch (error) { + console.error("Failed to resolve Lightning address:", error); + toast.error( + error instanceof Error + ? error.message + : "Failed to resolve Lightning address", + ); + } finally { + setSending(false); + } + return; + } + + // Parse BOLT11 invoice + const details = parseInvoice(input); + if (!details) { + toast.error("Invalid Lightning invoice"); + return; + } + + setInvoiceDetails(details); + setSendStep("confirm"); + } + + // Auto-proceed to confirm when valid invoice with amount is entered + function handleInvoiceChange(value: string) { + setSendInvoice(value); + + // If it looks like an invoice, try to parse it + if (value.toLowerCase().startsWith("ln")) { + const details = parseInvoice(value); + // Only auto-proceed if invoice has an amount + if (details && details.amount !== undefined) { + setInvoiceDetails(details); + setSendStep("confirm"); + } + } + } + + // Resolve Lightning address to invoice with security validations + async function resolveLightningAddress(address: string, amountSats: number) { + try { + const [username, domain] = address.split("@"); + if (!username || !domain) { + throw new Error("Invalid Lightning address format"); + } + + // Security: Enforce HTTPS only + const lnurlUrl = `https://${domain}/.well-known/lnurlp/${username}`; + + // Security: Add timeout for fetch requests (5 seconds) + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + try { + const response = await fetch(lnurlUrl, { + signal: controller.signal, + }); + clearTimeout(timeoutId); + + if (!response.ok) { + throw new Error( + `Failed to fetch Lightning address: ${response.statusText}`, + ); + } + + const data = await response.json(); + + if (data.status === "ERROR") { + throw new Error(data.reason || "Lightning address lookup failed"); + } + + // Validate callback URL uses HTTPS + if (!data.callback || !data.callback.startsWith("https://")) { + throw new Error("Invalid callback URL (must use HTTPS)"); + } + + // Check amount limits (amounts are in millisats) + const amountMsat = amountSats * 1000; + if (data.minSendable && amountMsat < data.minSendable) { + throw new Error( + `Amount too small. Minimum: ${data.minSendable / 1000} sats`, + ); + } + if (data.maxSendable && amountMsat > data.maxSendable) { + throw new Error( + `Amount too large. Maximum: ${data.maxSendable / 1000} sats`, + ); + } + + // Fetch invoice from callback + const callbackUrl = new URL(data.callback); + callbackUrl.searchParams.set("amount", amountMsat.toString()); + + const invoiceController = new AbortController(); + const invoiceTimeoutId = setTimeout( + () => invoiceController.abort(), + 5000, + ); + + const invoiceResponse = await fetch(callbackUrl.toString(), { + signal: invoiceController.signal, + }); + clearTimeout(invoiceTimeoutId); + + if (!invoiceResponse.ok) { + throw new Error( + `Failed to get invoice: ${invoiceResponse.statusText}`, + ); + } + + const invoiceData = await invoiceResponse.json(); + + if (invoiceData.status === "ERROR") { + throw new Error(invoiceData.reason || "Failed to generate invoice"); + } + + return invoiceData.pr; // The BOLT11 invoice + } catch (fetchError) { + if (fetchError instanceof Error && fetchError.name === "AbortError") { + throw new Error("Request timeout (5 seconds)"); + } + throw fetchError; + } + } catch (error) { + console.error("Lightning address resolution failed:", error); + throw error; + } + } + + function handleBackToInput() { + setSendStep("input"); + setInvoiceDetails(null); + } + + async function handleSendPayment() { + setSending(true); + try { + // Convert sats to millisats for NWC protocol + const amount = sendAmount ? parseInt(sendAmount) * 1000 : undefined; + await payInvoice(sendInvoice, amount); + toast.success("Payment sent successfully"); + resetSendDialog(); + setSendDialogOpen(false); + // Reload transactions + reloadTransactions(); + } catch (error) { + console.error("Payment failed:", error); + toast.error(error instanceof Error ? error.message : "Payment failed"); + } finally { + setSending(false); + } + } + + function resetSendDialog() { + setSendInvoice(""); + setSendAmount(""); + setSendStep("input"); + setInvoiceDetails(null); + } + + async function handleGenerateInvoice() { + const amountSats = parseInt(receiveAmount); + if (!amountSats || amountSats <= 0) { + toast.error("Please enter a valid amount"); + return; + } + + setGenerating(true); + try { + // Convert sats to millisats for NWC protocol + const amountMillisats = amountSats * 1000; + const result = await makeInvoice(amountMillisats, { + description: receiveDescription || undefined, + }); + + if (!result.invoice) { + throw new Error("No invoice returned from wallet"); + } + + setGeneratedInvoice(result.invoice); + // Extract payment hash if available + if (result.payment_hash) { + setGeneratedPaymentHash(result.payment_hash); + } + + // Generate QR code + const qrDataUrl = await QRCode.toDataURL(result.invoice.toUpperCase(), { + width: 256, + margin: 2, + color: { + dark: "#000000", + light: "#ffffff", + }, + }); + setInvoiceQR(qrDataUrl); + + toast.success("Invoice generated"); + } catch (error) { + console.error("Failed to generate invoice:", error); + toast.error( + error instanceof Error ? error.message : "Failed to generate invoice", + ); + } finally { + setGenerating(false); + } + } + + 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 resetReceiveDialog() { + setGeneratedInvoice(""); + setGeneratedPaymentHash(""); + setInvoiceQR(""); + setReceiveAmount(""); + setReceiveDescription(""); + setCopied(false); + } + + function handleDisconnect() { + // Clear NWC connection from Grimoire state first + disconnectNWCFromState(); + // Then clear the wallet service + disconnect(); + setDisconnectDialogOpen(false); + toast.success("Wallet disconnected"); + } + + function handleTransactionClick(tx: Transaction) { + setSelectedTransaction(tx); + setDetailDialogOpen(true); + } + + function formatSats(millisats: number | undefined): string { + if (millisats === undefined) return "—"; + return Math.floor(millisats / 1000).toLocaleString(); + } + + function formatFullDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleString(); + } + + // Process transactions to include day markers + const transactionsWithMarkers = useMemo(() => { + if (!transactions || transactions.length === 0) return []; + + const items: Array< + | { type: "transaction"; data: Transaction } + | { type: "day-marker"; data: string; timestamp: number } + > = []; + + transactions.forEach((transaction, index) => { + // Add day marker if this is the first transaction or if day changed + if (index === 0) { + items.push({ + type: "day-marker", + data: formatDayMarker(transaction.created_at), + timestamp: transaction.created_at, + }); + } else if ( + isDifferentDay( + transactions[index - 1].created_at, + transaction.created_at, + ) + ) { + items.push({ + type: "day-marker", + data: formatDayMarker(transaction.created_at), + timestamp: transaction.created_at, + }); + } + + items.push({ type: "transaction", data: transaction }); + }); + + return items; + }, [transactions]); + + if (!isConnected || !wallet) { + return ( +
+ + + + + No Wallet Connected + + + +

+ Connect a Nostr Wallet Connect (NWC) enabled Lightning wallet to + send and receive payments. +

+ +
+
+ +
+ ); + } + + return ( +
+ {/* Header */} +
+ {/* Left: Wallet Name + Status */} +
+ + {walletInfo?.alias || "Lightning Wallet"} + +
+
+ Connected +
+
+ + {/* Right: Info Dropdown, Refresh, Disconnect */} +
+ {walletInfo && ( + + + + + +
+
+
+ Wallet Information +
+ {walletInfo.network && ( +
+ Network + + {walletInfo.network} + +
+ )} + {state.nwcConnection?.relays && + state.nwcConnection.relays.length > 0 && ( + + )} +
+ +
+
Capabilities
+
+ {walletInfo.methods.map((method) => ( + + {method} + + ))} +
+
+ + {walletInfo.notifications && + walletInfo.notifications.length > 0 && ( +
+
+ Notifications +
+
+ {walletInfo.notifications.map((notification) => ( + + {notification} + + ))} +
+
+ )} +
+
+
+ )} + + + + + + Refresh Balance + + + + + + + Disconnect Wallet + +
+
+ + {/* Big Centered Balance */} +
+
+ {formatSats(balance)} +
+
+ + {/* Send / Receive Buttons */} + {walletInfo && + (walletInfo.methods.includes("pay_invoice") || + walletInfo.methods.includes("make_invoice")) && ( +
+
+ {walletInfo.methods.includes("make_invoice") && ( + + )} + {walletInfo.methods.includes("pay_invoice") && ( + + )} +
+
+ )} + + {/* Transaction History */} +
+ {walletInfo?.methods.includes("list_transactions") ? ( + loading ? ( +
+ +
+ ) : txLoadFailed ? ( +
+

+ Failed to load transaction history +

+ +
+ ) : transactionsWithMarkers.length === 0 ? ( +
+

+ No transactions found +

+
+ ) : ( + { + if (item.type === "day-marker") { + return ( +
+ +
+ ); + } + + const tx = item.data; + const txLabel = + tx.description || + (tx.type === "incoming" ? "Received" : "Payment"); + + return ( +
handleTransactionClick(tx)} + > +
+ {tx.type === "incoming" ? ( + + ) : ( + + )} + {txLabel} +
+
+

+ {formatSats(tx.amount)} +

+
+
+ ); + }} + components={{ + Footer: () => + loadingMore ? ( +
+ +
+ ) : !hasMore && transactions.length > 0 ? ( +
+ No more transactions +
+ ) : null, + }} + /> + ) + ) : ( +
+

+ Transaction history not available +

+
+ )} +
+ + {/* Disconnect Confirmation Dialog */} + + + + Disconnect Wallet? + + This will disconnect your Lightning wallet. You can reconnect at + any time. + + + + + + + + + + {/* Transaction Detail Dialog */} + + + + Transaction Details + + + {selectedTransaction && ( +
+
+ {selectedTransaction.type === "incoming" ? ( + + ) : ( + + )} +
+

+ {selectedTransaction.type === "incoming" + ? "Received" + : "Sent"} +

+

+ {formatSats(selectedTransaction.amount)} sats +

+
+
+ +
+ {selectedTransaction.description && ( +
+ +

{selectedTransaction.description}

+
+ )} + +
+ +

+ {formatFullDate(selectedTransaction.created_at)} +

+
+ + {selectedTransaction.fees_paid !== undefined && + selectedTransaction.fees_paid > 0 && ( +
+ +

+ {formatSats(selectedTransaction.fees_paid)} sats +

+
+ )} + + {selectedTransaction.payment_hash && ( +
+ +

+ {selectedTransaction.payment_hash} +

+
+ )} + + {selectedTransaction.preimage && ( +
+ +

+ {selectedTransaction.preimage} +

+
+ )} +
+
+ )} + + + + +
+
+ + {/* Send Dialog */} + { + setSendDialogOpen(open); + if (!open) resetSendDialog(); + }} + > + + + Send Payment + + {sendStep === "input" + ? "Pay a Lightning invoice or Lightning address. Amount can be overridden if the invoice allows it." + : "Confirm payment details before sending."} + + + + {sendStep === "input" ? ( +
+
+ + handleInvoiceChange(e.target.value)} + className="font-mono text-xs" + /> +
+ +
+ + setSendAmount(e.target.value)} + /> +

+ Leave empty for invoices with fixed amounts +

+
+ + +
+ ) : ( +
+
+
+

Confirm Payment

+
+ {invoiceDetails?.amount && !sendAmount && ( +
+ Amount: + + {Math.floor(invoiceDetails.amount).toLocaleString()}{" "} + sats + +
+ )} + {sendAmount && ( +
+ Amount: + + {parseInt(sendAmount).toLocaleString()} sats + +
+ )} + {invoiceDetails?.description && ( +
+ + Description: + + + {invoiceDetails.description} + +
+ )} +
+
+
+ +
+ + +
+
+ )} +
+
+ + {/* Receive Dialog */} + { + setReceiveDialogOpen(open); + if (!open) resetReceiveDialog(); + }} + > + + + Receive Payment + + Generate a Lightning invoice to receive sats. + {checkingPayment && " Waiting for payment..."} + + + +
+ {!generatedInvoice ? ( + <> +
+ + setReceiveAmount(e.target.value)} + disabled={generating} + /> +
+ +
+ + setReceiveDescription(e.target.value)} + disabled={generating} + /> +
+ + + + ) : ( + <> +
+ {invoiceQR && ( +
+ Invoice QR Code + {checkingPayment && ( +
+ +
+ )} +
+ )} +
+ +
+ + +
+ +
+ {generatedInvoice} +
+
+ + +
+ + )} +
+
+
+
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 6ec02c5..4d4671f 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -42,6 +42,7 @@ const SpellbooksViewer = lazy(() => const BlossomViewer = lazy(() => import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })), ); +const WalletViewer = lazy(() => import("./WalletViewer")); const CountViewer = lazy(() => import("./CountViewer")); // Loading fallback component @@ -222,6 +223,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { /> ); break; + case "wallet": + content = ; + break; default: content = (
diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 0075e19..3b04c00 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -99,6 +99,10 @@ export default function UserMenu() { ); } + function openWallet() { + addWindow("wallet", {}, "Wallet"); + } + async function logout() { if (!account) return; accounts.removeAccount(account); @@ -155,6 +159,7 @@ export default function UserMenu() { {/* Wallet Info Dialog */} @@ -295,7 +300,7 @@ export default function UserMenu() { - {account ? ( + {account && ( <> + + )} - {/* Wallet Section */} - {nwcConnection ? ( - setShowWalletInfo(true)} - > -
- - {balance !== undefined || - nwcConnection.balance !== undefined ? ( - - {formatBalance(balance ?? nwcConnection.balance)} - - ) : null} -
-
- - - {getWalletName()} - -
-
- ) : ( - setShowConnectWallet(true)} - > - - Connect Wallet - - )} + {/* Wallet Section - Always show */} + {nwcConnection ? ( + +
+ + {balance !== undefined || + nwcConnection.balance !== undefined ? ( + + {formatBalance(balance ?? nwcConnection.balance)} + + ) : null} +
+
+ + + {getWalletName()} + +
+
+ ) : ( + setShowConnectWallet(true)} + > + + Connect Wallet + + )} + {account && ( + <> {relays && relays.length > 0 && ( <> @@ -398,64 +407,44 @@ export default function UserMenu() { Log out - - - - - Theme - - - {availableThemes.map((theme) => ( - setTheme(theme.id)} - > - - {theme.name} - - ))} - - - ) : ( + )} + + {!account && ( <> + setShowLogin(true)}> Log in - - - - - Theme - - - {availableThemes.map((theme) => ( - setTheme(theme.id)} - > - - {theme.name} - - ))} - - )} + + {/* Theme Section - Always show */} + + + + + Theme + + + {availableThemes.map((theme) => ( + setTheme(theme.id)} + > + + {theme.name} + + ))} + +
diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 61ac112..07c5bdd 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -118,6 +118,50 @@ export function useWallet() { return await refreshBalanceService(); } + /** + * List recent transactions + * @param options - Pagination and filter options + */ + 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); + } + + /** + * 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 */ @@ -143,6 +187,12 @@ export function useWallet() { 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, }; diff --git a/src/types/app.ts b/src/types/app.ts index 6630a38..681b678 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -21,6 +21,7 @@ export type AppId = | "spells" | "spellbooks" | "blossom" + | "wallet" | "win"; export interface WindowInstance { diff --git a/src/types/man.ts b/src/types/man.ts index ec0fa8b..6493e5e 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -785,4 +785,16 @@ export const manPages: Record = { }, defaultProps: { subcommand: "servers" }, }, + wallet: { + name: "wallet", + section: "1", + synopsis: "wallet", + description: + "View and manage your Nostr Wallet Connect (NWC) Lightning wallet. Display wallet balance, transaction history, send/receive payments, and view wallet capabilities. The wallet interface adapts based on the methods supported by your connected wallet provider.", + examples: ["wallet Open wallet viewer and manage Lightning payments"], + seeAlso: ["profile"], + appId: "wallet", + category: "Nostr", + defaultProps: {}, + }, };