diff --git a/src/components/WalletViewer.tsx b/src/components/WalletViewer.tsx new file mode 100644 index 0000000..065c4a8 --- /dev/null +++ b/src/components/WalletViewer.tsx @@ -0,0 +1,589 @@ +/** + * WalletViewer Component + * + * Displays NWC wallet information and provides UI for wallet operations. + * Dynamically shows features based on wallet capabilities (methods). + * + * Features: + * - Balance display with real-time updates + * - Transaction history (if list_transactions supported) + * - Send/Receive Lightning payments + * - Budget information (if get_budget supported) + * - Wallet info and capabilities + */ + +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { + Wallet, + RefreshCw, + Send, + Download, + Info, + AlertCircle, + Copy, + Check, + Zap, + ArrowUpRight, + ArrowDownLeft, +} from "lucide-react"; +import { useWallet } from "@/hooks/useWallet"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import QRCode from "qrcode"; + +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[]; +} + +export default function WalletViewer() { + const { + wallet, + balance, + isConnected, + getInfo, + refreshBalance, + listTransactions, + makeInvoice, + payInvoice, + } = useWallet(); + + const [walletInfo, setWalletInfo] = useState(null); + const [transactions, setTransactions] = useState([]); + const [loading, setLoading] = useState(false); + const [activeTab, setActiveTab] = useState("overview"); + + // Send tab state + const [sendInvoice, setSendInvoice] = useState(""); + const [sendAmount, setSendAmount] = useState(""); + const [sending, setSending] = useState(false); + + // Receive tab state + const [receiveAmount, setReceiveAmount] = useState(""); + const [receiveDescription, setReceiveDescription] = useState(""); + const [generatedInvoice, setGeneratedInvoice] = useState(""); + const [invoiceQR, setInvoiceQR] = useState(""); + const [generating, setGenerating] = useState(false); + const [copied, setCopied] = useState(false); + + // Load wallet info on mount + useEffect(() => { + if (isConnected) { + loadWalletInfo(); + } + }, [isConnected]); + + // Load transactions when switching to that tab + useEffect(() => { + if ( + activeTab === "transactions" && + walletInfo?.methods.includes("list_transactions") + ) { + loadTransactions(); + } + }, [activeTab, walletInfo]); + + async function loadWalletInfo() { + try { + const info = await getInfo(); + setWalletInfo(info); + } catch (error) { + console.error("Failed to load wallet info:", error); + toast.error("Failed to load wallet info"); + } + } + + async function loadTransactions() { + if (!walletInfo?.methods.includes("list_transactions")) return; + + setLoading(true); + try { + const result = await listTransactions({ limit: 50 }); + setTransactions(result.transactions || []); + } catch (error) { + console.error("Failed to load transactions:", error); + toast.error("Failed to load transactions"); + } finally { + setLoading(false); + } + } + + async function handleRefreshBalance() { + 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 handleSendPayment() { + if (!sendInvoice.trim()) { + toast.error("Please enter an invoice"); + return; + } + + setSending(true); + try { + const amount = sendAmount ? parseInt(sendAmount) : undefined; + await payInvoice(sendInvoice, amount); + toast.success("Payment sent successfully"); + setSendInvoice(""); + setSendAmount(""); + } catch (error) { + console.error("Payment failed:", error); + toast.error(error instanceof Error ? error.message : "Payment failed"); + } finally { + setSending(false); + } + } + + async function handleGenerateInvoice() { + const amount = parseInt(receiveAmount); + if (!amount || amount <= 0) { + toast.error("Please enter a valid amount"); + return; + } + + setGenerating(true); + try { + const result = await makeInvoice(amount, { + description: receiveDescription || undefined, + }); + + if (!result.invoice) { + throw new Error("No invoice returned from wallet"); + } + + setGeneratedInvoice(result.invoice); + + // Generate QR code + const qrDataUrl = await QRCode.toDataURL(result.invoice.toUpperCase(), { + width: 300, + 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); + } + } + + function handleCopyInvoice() { + navigator.clipboard.writeText(generatedInvoice); + setCopied(true); + toast.success("Invoice copied to clipboard"); + setTimeout(() => setCopied(false), 2000); + } + + function formatSats(millisats: number | undefined): string { + if (millisats === undefined) return "—"; + return Math.floor(millisats / 1000).toLocaleString(); + } + + function formatDate(timestamp: number): string { + return new Date(timestamp * 1000).toLocaleString(); + } + + if (!isConnected || !wallet) { + return ( +
+ + + + + No Wallet Connected + + + +

+ Connect a Nostr Wallet Connect (NWC) enabled Lightning wallet to + use this feature. +

+
+
+
+ ); + } + + return ( +
+
+
+
+ +
+

+ {walletInfo?.alias || "Lightning Wallet"} +

+

+ Balance: {formatSats(balance)} sats +

+
+
+ +
+
+ + +
+ + + + Overview + + + + Send + + + + Receive + + {walletInfo?.methods.includes("list_transactions") && ( + + + Transactions + + )} + +
+ + +
+ + + + Wallet Information + + + {walletInfo?.alias && ( +
+ + Alias + + + {walletInfo.alias} + +
+ )} + {walletInfo?.network && ( +
+ + Network + + + {walletInfo.network} + +
+ )} +
+ + Balance + + + {formatSats(balance)} sats + +
+
+
+ + + + Capabilities + + +
+ {walletInfo?.methods.map((method) => ( +
+ + {method} +
+ ))} +
+
+
+ + {walletInfo?.notifications && + walletInfo.notifications.length > 0 && ( + + + Notifications + + +
+ {walletInfo.notifications.map((notification) => ( +
+ + {notification} +
+ ))} +
+
+
+ )} +
+ + + + + Send Payment + + +
+ + setSendInvoice(e.target.value)} + disabled={sending} + /> +
+ +
+ + setSendAmount(e.target.value)} + disabled={sending} + /> +
+ + +
+
+
+ + + + + Receive Payment + + +
+ + setReceiveAmount(e.target.value)} + disabled={generating} + /> +
+ +
+ + setReceiveDescription(e.target.value)} + disabled={generating} + /> +
+ + + + {generatedInvoice && ( +
+
+ {invoiceQR && ( + Invoice QR Code + )} +
+ +
+
+ + +
+
+ {generatedInvoice} +
+
+
+ )} +
+
+
+ + {walletInfo?.methods.includes("list_transactions") && ( + + + + Transaction History + + + {loading ? ( +
+ +
+ ) : transactions.length === 0 ? ( +

+ No transactions found +

+ ) : ( +
+ {transactions.map((tx, index) => ( +
+
+ {tx.type === "incoming" ? ( + + ) : ( + + )} +
+

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

+ {tx.description && ( +

+ {tx.description} +

+ )} +

+ {formatDate(tx.created_at)} +

+
+
+
+

+ {tx.type === "incoming" ? "+" : "-"} + {formatSats(tx.amount)} sats +

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

+ Fee: {formatSats(tx.fees_paid)} sats +

+ )} +
+
+ ))} +
+ )} +
+
+
+ )} +
+
+
+
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index e43055e..4ef7868 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 @@ -220,6 +221,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { /> ); break; + case "wallet": + content = ; + break; default: content = (
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 c566eed..8a995a4 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -700,4 +700,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: {}, + }, };