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: {}, + }, };