From 7fae344dd9a93cd6db95c050d8741f87492b8493 Mon Sep 17 00:00:00 2001 From: Alejandro Date: Sun, 18 Jan 2026 11:14:47 +0100 Subject: [PATCH] feat: add Nostr Wallet Connect (NWC) integration (#131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Nostr Wallet Connect (NWC) integration Add NWC (NIP-47) support to connect Lightning wallets: - Add NWCConnection type and state management - Implement custom NWC client service for wallet communication - Create ConnectWalletDialog for entering connection strings - Add wallet button to user menu showing balance - Display wallet info (balance, alias) in user menu dropdown - Support get_info, get_balance, pay_invoice, make_invoice commands - Persist wallet connection to localStorage - Install applesauce-wallet (for future NIP-60 support) Note: Current implementation uses custom NWC client. Can optionally switch to @getalby/sdk for official Alby SDK support. * refactor: use applesauce-wallet-connect and move wallet to header Replace custom NWC client implementation with applesauce-wallet-connect: - Install applesauce-wallet-connect for official NIP-47 support - Create nwc.ts service wrapper for WalletConnect singleton - Update NWCConnection type to match WalletConnectURI interface - Use service/relays/secret properties instead of custom names Move wallet display from user menu to header: - Create standalone WalletButton component - Add WalletButton to header next to UserMenu - Remove wallet UI from user menu dropdown - Show balance in header with yellow zap icon - Clicking wallet button opens connect dialog This provides better UX with wallet status visible in header and uses the official applesauce implementation for reliability. * feat: improve wallet button UX and add detailed info dialog UI improvements: - Remove border and padding from wallet button (use ghost variant) - Remove "sats" suffix from balance display (show just the number) - Change click behavior to show detailed wallet info dialog Wallet info dialog: - Show balance prominently without suffix - Display wallet alias if available - Show lightning address (lud16) if present - List all supported NWC methods as badges - Display connected relay URLs - Add disconnect button with confirmation toast This provides a cleaner header appearance and better wallet management with all details accessible in one place. * refactor: move wallet functionality to user menu Move wallet connection and info from separate header button into the user menu dropdown for better organization. Changes: - Remove standalone WalletButton component - Add wallet section to user menu dropdown - Show "Connect Wallet" option when no wallet is connected - Display wallet balance and alias when connected - Clicking wallet info opens detailed dialog with: - Balance (without suffix) - Wallet name/alias - Lightning address (lud16) - Supported NWC methods - Connected relay URLs - Disconnect button This consolidates all user-related settings (account, relays, blossom servers, wallet) in one consistent location. * feat: improve wallet UX with profile-based naming and better layout UX improvements: - Use service pubkey username/profile as wallet name instead of alias - Remove format hint text from connection string input - Move wallet to single row directly below user name (remove label) - Use RelayLink component for relay URLs in wallet info dialog - Show wallet name from service profile with fallback to alias/pubkey This provides better integration with Nostr profiles and a cleaner, more compact menu layout. The wallet service provider's identity is now shown using their actual Nostr profile name. * feat: add persistent wallet service with auto-updating balance Implement comprehensive NWC wallet management architecture: **Service Layer** (`src/services/nwc.ts`): - Maintain singleton WalletConnect instance across app lifetime - Poll balance every 30 seconds for automatic updates - Subscribe to NIP-47 notifications (kind 23197) for real-time updates - Expose RxJS observable (balance$) for reactive balance changes - Restore wallet from saved connection on app startup - Proper cleanup on disconnect **Hook Layer** (`src/hooks/useWallet.ts`): - useWallet() hook exposes wallet throughout the app - Provides methods: payInvoice, makeInvoice, getBalance, etc. - Auto-syncs balance updates with Jotai state - Handles wallet restoration on mount - Type-safe wrapper around WalletConnect API **UI Updates**: - Add refresh button to wallet info dialog - Auto-update balance display when transactions occur - Proper cleanup on wallet disconnect **Architecture**: ``` User Action → useWallet() → NWC Service → WalletConnect ↓ ↓ Update State ← balance$ observable ← Polling/Notifications ``` This enables: - Real-time balance updates when paying/receiving - Easy wallet access: `const { payInvoice, balance } = useWallet()` - Persistent connection across page reloads - Automatic polling as fallback for notifications * refactor: make NWC fully reactive with notifications and graceful balance handling Changes: - Remove polling mechanism in favor of pure reactive notifications$ observable - Subscribe to wallet.notifications$ for real-time balance updates - Make balance display conditional (only show if available) - Fix TypeScript errors (notification.type access, unused variable) - Remove Jotai callback mechanism for balance updates - Use use$() directly for reactive balance subscription - Update comments to reflect reactive architecture (no polling) The wallet now updates balance automatically when payments are sent/received via NIP-47 notifications, with no polling overhead. * feat: improve wallet UX with profile-based naming and better layout Improvements to NWC wallet UI: - Add separator between user info and wallet section in menu - Show wallet icon instead of zap icon for better clarity - Display connection status indicator (green/red dot) in both menu and dialog - Make wallet service username clickable in wallet info dialog to open profile - Use wallet relays as hints when fetching service profile for better resolution - Enhanced useProfile hook to accept optional relay hints parameter The wallet now properly resolves service profiles using the NWC relay and shows visual connection status at a glance. * fix: remove toast descriptions for better contrast --------- Co-authored-by: Claude --- package-lock.json | 151 ++++++++++++++++ package.json | 2 + src/components/ConnectWalletDialog.tsx | 176 ++++++++++++++++++ src/components/nostr/user-menu.tsx | 241 ++++++++++++++++++++++++- src/core/logic.ts | 67 +++++++ src/core/state.ts | 29 +++ src/hooks/useProfile.ts | 16 +- src/hooks/useWallet.ts | 149 +++++++++++++++ src/services/nwc.ts | 150 +++++++++++++++ src/types/app.ts | 25 +++ 10 files changed, 1000 insertions(+), 6 deletions(-) create mode 100644 src/components/ConnectWalletDialog.tsx create mode 100644 src/hooks/useWallet.ts create mode 100644 src/services/nwc.ts diff --git a/package-lock.json b/package-lock.json index 6ef4afb..05e8e20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,8 @@ "applesauce-react": "^5.0.1", "applesauce-relay": "^5.0.0", "applesauce-signers": "^5.0.0", + "applesauce-wallet": "^5.0.0", + "applesauce-wallet-connect": "^5.0.1", "blossom-client-sdk": "^4.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -127,6 +129,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@apocentre/alias-sampling": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@apocentre/alias-sampling/-/alias-sampling-0.5.3.tgz", + "integrity": "sha512-7UDWIIF9hIeJqfKXkNIzkVandlwLf1FWTSdrb9iXvOP8oF544JRXQjCbiTmCv2c9n44n/FIWtehhBfNuAx2CZA==", + "license": "GPL" + }, "node_modules/@asamuzakjp/css-color": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.1.tgz", @@ -1502,6 +1510,21 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@gandlaf21/bc-ur": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@gandlaf21/bc-ur/-/bc-ur-1.1.12.tgz", + "integrity": "sha512-AQfbZJ1o1AdK9/W9VcTyMkwp6iZWDWQQV2SGep2ygJUkTNaafSjdWLUgpc6Uo/VLlGYaS9A28gCh+GVtAdwTpA==", + "license": "MIT", + "dependencies": { + "@apocentre/alias-sampling": "^0.5.3", + "@noble/hashes": "^1.3.3", + "bignumber.js": "^9.0.1", + "buffer": "^6.0.3", + "cbor-sync": "^1.0.4", + "cborg": "^4.0.9", + "jsbi": "3.1.5" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -5707,6 +5730,40 @@ "url": "lightning:nostrudel@geyser.fund" } }, + "node_modules/applesauce-wallet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/applesauce-wallet/-/applesauce-wallet-5.0.0.tgz", + "integrity": "sha512-hPn3tXQEhxzI+ar8Lxp3D27QFjuk2+sf8tbj6m0G8aks4HTj0Pl+ucoUfU7e7DCgvr0yDv0ObWQSxX8KHGixaA==", + "license": "MIT", + "dependencies": { + "@cashu/cashu-ts": "^3.1.1", + "@gandlaf21/bc-ur": "^1.1.12", + "applesauce-actions": "^5.0.0", + "applesauce-common": "^5.0.0", + "applesauce-core": "^5.0.0", + "rxjs": "^7.8.1" + }, + "funding": { + "type": "lightning", + "url": "lightning:nostrudel@geyser.fund" + } + }, + "node_modules/applesauce-wallet-connect": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/applesauce-wallet-connect/-/applesauce-wallet-connect-5.0.1.tgz", + "integrity": "sha512-k/Gl2IIjfQelW4deN/0M9/I3uznUMZalGAP9/wPgwmAtUyaEHb8YJpOdxqLwCQ98vZMTAcwgK6hmXkAPqA6NTg==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "^1.7.1", + "applesauce-common": "^5.0.0", + "applesauce-core": "^5.0.0", + "rxjs": "^7.8.1" + }, + "funding": { + "type": "lightning", + "url": "lightning:nostrudel@geyser.fund" + } + }, "node_modules/arg": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", @@ -5820,6 +5877,26 @@ "dev": true, "license": "MIT" }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/baseline-browser-mapping": { "version": "2.8.31", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.31.tgz", @@ -5840,6 +5917,15 @@ "require-from-string": "^2.0.2" } }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", @@ -5967,6 +6053,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -6027,6 +6137,21 @@ ], "license": "CC-BY-4.0" }, + "node_modules/cbor-sync": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cbor-sync/-/cbor-sync-1.0.4.tgz", + "integrity": "sha512-GWlXN4wiz0vdWWXBU71Dvc1q3aBo0HytqwAZnXF1wOwjqNnDWA1vZ1gDMFLlqohak31VQzmhiYfiCX5QSSfagA==", + "license": "MIT" + }, + "node_modules/cborg": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/cborg/-/cborg-4.3.2.tgz", + "integrity": "sha512-l+QzebEAG0vb09YKkaOrMi2zmm80UNjmbvocMIeW5hO7JOXWdrQ/H49yOKfYX0MBgrj/KWgatBnEgRXyNyKD+A==", + "license": "Apache-2.0", + "bin": { + "cborg": "lib/bin.js" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -7561,6 +7686,26 @@ "node": ">= 14" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -7834,6 +7979,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-3.1.5.tgz", + "integrity": "sha512-w2BY0VOYC1ahe+w6Qhl4SFoPvPsZ9NPHY4bwass+LCgU7RK3PBoVQlQ3G1s7vI8W3CYyJiEXcbKF7FIM/L8q3Q==", + "license": "Apache-2.0" + }, "node_modules/jsdom": { "version": "27.4.0", "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", diff --git a/package.json b/package.json index 4a13e62..160f261 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,8 @@ "applesauce-react": "^5.0.1", "applesauce-relay": "^5.0.0", "applesauce-signers": "^5.0.0", + "applesauce-wallet": "^5.0.0", + "applesauce-wallet-connect": "^5.0.1", "blossom-client-sdk": "^4.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/components/ConnectWalletDialog.tsx b/src/components/ConnectWalletDialog.tsx new file mode 100644 index 0000000..7f05fb5 --- /dev/null +++ b/src/components/ConnectWalletDialog.tsx @@ -0,0 +1,176 @@ +import { useState, useEffect } from "react"; +import { toast } from "sonner"; +import { Loader2, Wallet, AlertCircle } from "lucide-react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { useGrimoire } from "@/core/state"; +import { createWalletFromURI } from "@/services/nwc"; + +interface ConnectWalletDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function ConnectWalletDialog({ + open, + onOpenChange, +}: ConnectWalletDialogProps) { + const [connectionString, setConnectionString] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const { setNWCConnection, updateNWCBalance, updateNWCInfo } = useGrimoire(); + + // Reset state when dialog closes + useEffect(() => { + if (!open) { + setConnectionString(""); + setLoading(false); + setError(null); + } + }, [open]); + + async function handleConnect() { + if (!connectionString.trim()) { + setError("Please enter a connection string"); + return; + } + + if (!connectionString.startsWith("nostr+walletconnect://")) { + setError( + "Invalid connection string. Must start with nostr+walletconnect://", + ); + return; + } + + setLoading(true); + setError(null); + + try { + // Create wallet instance from connection string + const wallet = createWalletFromURI(connectionString); + + // Test the connection by getting wallet info + const info = await wallet.getInfo(); + + // Get initial balance + let balance: number | undefined; + try { + const balanceResult = await wallet.getBalance(); + balance = balanceResult.balance; + } catch (err) { + console.warn("[NWC] Failed to get balance:", err); + // Balance is optional, continue anyway + } + + // Get connection details from the wallet instance + const serialized = wallet.toJSON(); + + // Save connection to state + setNWCConnection({ + service: serialized.service, + relays: serialized.relays, + secret: serialized.secret, + lud16: serialized.lud16, + balance, + info: { + alias: info.alias, + methods: info.methods, + notifications: info.notifications, + }, + }); + + // Update balance if we got it + if (balance !== undefined) { + updateNWCBalance(balance); + } + + // Update info + updateNWCInfo({ + alias: info.alias, + methods: info.methods, + notifications: info.notifications, + }); + + // Show success toast + toast.success("Wallet Connected"); + + // Close dialog + onOpenChange(false); + } catch (err) { + console.error("Wallet connection error:", err); + setError(err instanceof Error ? err.message : "Failed to connect wallet"); + toast.error("Failed to connect wallet"); + } finally { + setLoading(false); + } + } + + return ( + + + + Connect Wallet + + Connect to a Nostr Wallet Connect (NWC) enabled Lightning wallet + + + +
+

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

+ + {error && ( +
+ + {error} +
+ )} + +
+ + setConnectionString(e.target.value)} + disabled={loading} + autoComplete="off" + /> +
+ + +
+
+
+ ); +} diff --git a/src/components/nostr/user-menu.tsx b/src/components/nostr/user-menu.tsx index 4bb555e..0075e19 100644 --- a/src/components/nostr/user-menu.tsx +++ b/src/components/nostr/user-menu.tsx @@ -1,4 +1,4 @@ -import { User, HardDrive, Palette } from "lucide-react"; +import { User, HardDrive, Palette, Wallet, X, RefreshCw } from "lucide-react"; import accounts from "@/services/accounts"; import { useProfile } from "@/hooks/useProfile"; import { use$ } from "applesauce-react/hooks"; @@ -17,13 +17,23 @@ import { DropdownMenuSubTrigger, DropdownMenuSubContent, } from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import Nip05 from "./nip05"; import { RelayLink } from "./RelayLink"; import SettingsDialog from "@/components/SettingsDialog"; import LoginDialog from "./LoginDialog"; +import ConnectWalletDialog from "@/components/ConnectWalletDialog"; import { useState } from "react"; import { useTheme } from "@/lib/themes"; +import { toast } from "sonner"; +import { useWallet } from "@/hooks/useWallet"; function UserAvatar({ pubkey }: { pubkey: string }) { const profile = useProfile(pubkey); @@ -56,13 +66,30 @@ function UserLabel({ pubkey }: { pubkey: string }) { export default function UserMenu() { const account = use$(accounts.active$); - const { state, addWindow } = useGrimoire(); + const { state, addWindow, disconnectNWC } = useGrimoire(); const relays = state.activeAccount?.relays; const blossomServers = state.activeAccount?.blossomServers; + const nwcConnection = state.nwcConnection; const [showSettings, setShowSettings] = useState(false); const [showLogin, setShowLogin] = useState(false); + const [showConnectWallet, setShowConnectWallet] = useState(false); + const [showWalletInfo, setShowWalletInfo] = useState(false); const { themeId, setTheme, availableThemes } = useTheme(); + // Get wallet service profile for display name, using wallet relays as hints + const walletServiceProfile = useProfile( + nwcConnection?.service, + nwcConnection?.relays, + ); + + // Use wallet hook for real-time balance and methods + const { + disconnect: disconnectWallet, + refreshBalance, + balance, + wallet, + } = useWallet(); + function openProfile() { if (!account?.pubkey) return; addWindow( @@ -77,10 +104,182 @@ export default function UserMenu() { accounts.removeAccount(account); } + function formatBalance(millisats?: number): string { + if (millisats === undefined) return "—"; + const sats = Math.floor(millisats / 1000); + return sats.toLocaleString(); + } + + function handleDisconnectWallet() { + // Disconnect from NWC service (stops notifications, clears wallet instance) + disconnectWallet(); + // Clear connection from state + disconnectNWC(); + setShowWalletInfo(false); + toast.success("Wallet disconnected"); + } + + async function handleRefreshBalance() { + try { + await refreshBalance(); + toast.success("Balance refreshed"); + } catch (_error) { + toast.error("Failed to refresh balance"); + } + } + + function getWalletName(): string { + if (!nwcConnection) return ""; + // Use service pubkey profile name, fallback to alias, then pubkey slice + return ( + getDisplayName(nwcConnection.service, walletServiceProfile) || + nwcConnection.info?.alias || + nwcConnection.service.slice(0, 8) + ); + } + + function openWalletServiceProfile() { + if (!nwcConnection?.service) return; + addWindow( + "profile", + { pubkey: nwcConnection.service }, + `Profile ${nwcConnection.service.slice(0, 8)}...`, + ); + setShowWalletInfo(false); + } + return ( <> + + + {/* Wallet Info Dialog */} + {nwcConnection && ( + + + + Wallet Info + + Connected Lightning wallet details + + + +
+ {/* Balance */} + {(balance !== undefined || + nwcConnection.balance !== undefined) && ( +
+ + Balance: + +
+ + {formatBalance(balance ?? nwcConnection.balance)} + + +
+
+ )} + + {/* Wallet Name */} +
+ Wallet: + +
+ + {/* Connection Status */} +
+ Status: +
+ + + {wallet ? "Connected" : "Disconnected"} + +
+
+ + {/* Lightning Address */} + {nwcConnection.lud16 && ( +
+ + Address: + + + {nwcConnection.lud16} + +
+ )} + + {/* Supported Methods */} + {nwcConnection.info?.methods && + nwcConnection.info.methods.length > 0 && ( +
+ + Supported Methods: + +
+ {nwcConnection.info.methods.map((method) => ( + + {method} + + ))} +
+
+ )} + + {/* Relays */} +
+ Relays: +
+ {nwcConnection.relays.map((relay) => ( + + ))} +
+
+ + {/* Disconnect Button */} + +
+
+
+ )} +