feat: add Nostr Wallet Connect (NWC) integration (#131)

* 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 <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-18 11:14:47 +01:00
committed by GitHub
parent c7cced2a9e
commit 7fae344dd9
10 changed files with 1000 additions and 6 deletions

View File

@@ -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<string | null>(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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Connect Wallet</DialogTitle>
<DialogDescription>
Connect to a Nostr Wallet Connect (NWC) enabled Lightning wallet
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Enter your wallet connection string. You can get this from your
wallet provider (Alby, Mutiny, etc.)
</p>
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
<div className="space-y-2">
<label
htmlFor="connection-string"
className="text-sm font-medium leading-none"
>
Connection String
</label>
<Input
id="connection-string"
placeholder="nostr+walletconnect://..."
value={connectionString}
onChange={(e) => setConnectionString(e.target.value)}
disabled={loading}
autoComplete="off"
/>
</div>
<Button
onClick={handleConnect}
disabled={loading || !connectionString.trim()}
className="w-full"
>
{loading ? (
<>
<Loader2 className="mr-2 size-4 animate-spin" />
Connecting...
</>
) : (
<>
<Wallet className="mr-2 size-4" />
Connect Wallet
</>
)}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -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 (
<>
<SettingsDialog open={showSettings} onOpenChange={setShowSettings} />
<LoginDialog open={showLogin} onOpenChange={setShowLogin} />
<ConnectWalletDialog
open={showConnectWallet}
onOpenChange={setShowConnectWallet}
/>
{/* Wallet Info Dialog */}
{nwcConnection && (
<Dialog open={showWalletInfo} onOpenChange={setShowWalletInfo}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Wallet Info</DialogTitle>
<DialogDescription>
Connected Lightning wallet details
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Balance */}
{(balance !== undefined ||
nwcConnection.balance !== undefined) && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Balance:
</span>
<div className="flex items-center gap-2">
<span className="text-lg font-semibold">
{formatBalance(balance ?? nwcConnection.balance)}
</span>
<Button
size="sm"
variant="ghost"
onClick={handleRefreshBalance}
title="Refresh balance"
>
<RefreshCw className="size-3.5" />
</Button>
</div>
</div>
)}
{/* Wallet Name */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Wallet:</span>
<button
onClick={openWalletServiceProfile}
className="text-sm font-medium hover:underline cursor-crosshair text-primary"
>
{getWalletName()}
</button>
</div>
{/* Connection Status */}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Status:</span>
<div className="flex items-center gap-2">
<span
className={`size-2 rounded-full ${
wallet ? "bg-green-500" : "bg-red-500"
}`}
/>
<span className="text-sm font-medium">
{wallet ? "Connected" : "Disconnected"}
</span>
</div>
</div>
{/* Lightning Address */}
{nwcConnection.lud16 && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">
Address:
</span>
<span className="text-sm font-mono">
{nwcConnection.lud16}
</span>
</div>
)}
{/* Supported Methods */}
{nwcConnection.info?.methods &&
nwcConnection.info.methods.length > 0 && (
<div>
<span className="text-sm text-muted-foreground">
Supported Methods:
</span>
<div className="mt-2 flex flex-wrap gap-1">
{nwcConnection.info.methods.map((method) => (
<span
key={method}
className="inline-flex items-center rounded-md bg-muted px-2 py-1 text-xs font-medium"
>
{method}
</span>
))}
</div>
</div>
)}
{/* Relays */}
<div>
<span className="text-sm text-muted-foreground">Relays:</span>
<div className="mt-2 space-y-1">
{nwcConnection.relays.map((relay) => (
<RelayLink
key={relay}
url={relay}
className="py-1"
urlClassname="text-xs"
iconClassname="size-3.5"
/>
))}
</div>
</div>
{/* Disconnect Button */}
<Button
onClick={handleDisconnectWallet}
variant="destructive"
className="w-full"
>
<X className="mr-2 size-4" />
Disconnect Wallet
</Button>
</div>
</DialogContent>
</Dialog>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -107,6 +306,44 @@ export default function UserMenu() {
</DropdownMenuLabel>
</DropdownMenuGroup>
<DropdownMenuSeparator />
{/* Wallet Section */}
{nwcConnection ? (
<DropdownMenuItem
className="cursor-crosshair flex items-center justify-between"
onClick={() => setShowWalletInfo(true)}
>
<div className="flex items-center gap-2">
<Wallet className="size-4 text-muted-foreground" />
{balance !== undefined ||
nwcConnection.balance !== undefined ? (
<span className="text-sm">
{formatBalance(balance ?? nwcConnection.balance)}
</span>
) : null}
</div>
<div className="flex items-center gap-1.5">
<span
className={`size-1.5 rounded-full ${
wallet ? "bg-green-500" : "bg-red-500"
}`}
/>
<span className="text-xs text-muted-foreground">
{getWalletName()}
</span>
</div>
</DropdownMenuItem>
) : (
<DropdownMenuItem
className="cursor-crosshair"
onClick={() => setShowConnectWallet(true)}
>
<Wallet className="size-4 text-muted-foreground mr-2" />
<span className="text-sm">Connect Wallet</span>
</DropdownMenuItem>
)}
{relays && relays.length > 0 && (
<>
<DropdownMenuSeparator />

View File

@@ -5,6 +5,7 @@ import {
WindowInstance,
RelayInfo,
LayoutConfig,
NWCConnection,
} from "@/types/app";
import { insertWindow } from "@/lib/layout-utils";
import { applyPresetToLayout, type LayoutPreset } from "@/lib/layout-presets";
@@ -526,3 +527,69 @@ export const clearActiveSpellbook = (state: GrimoireState): GrimoireState => {
activeSpellbook: undefined,
};
};
/**
* Sets or updates the NWC (Nostr Wallet Connect) connection.
*/
export const setNWCConnection = (
state: GrimoireState,
connection: NWCConnection,
): GrimoireState => {
return {
...state,
nwcConnection: {
...connection,
lastConnected: Date.now(),
},
};
};
/**
* Updates the balance of the current NWC connection.
*/
export const updateNWCBalance = (
state: GrimoireState,
balance: number,
): GrimoireState => {
if (!state.nwcConnection) {
return state;
}
return {
...state,
nwcConnection: {
...state.nwcConnection,
balance,
},
};
};
/**
* Updates the info of the current NWC connection.
*/
export const updateNWCInfo = (
state: GrimoireState,
info: NWCConnection["info"],
): GrimoireState => {
if (!state.nwcConnection) {
return state;
}
return {
...state,
nwcConnection: {
...state.nwcConnection,
info,
},
};
};
/**
* Disconnects and clears the current NWC connection.
*/
export const disconnectNWC = (state: GrimoireState): GrimoireState => {
return {
...state,
nwcConnection: undefined,
};
};

View File

@@ -345,6 +345,31 @@ export const useGrimoire = () => {
dispatch({ type: "DISCARD_TEMP" });
}, [dispatch]);
const setNWCConnection = useCallback(
(connection: any) => {
setState((prev) => Logic.setNWCConnection(prev, connection));
},
[setState],
);
const updateNWCBalance = useCallback(
(balance: number) => {
setState((prev) => Logic.updateNWCBalance(prev, balance));
},
[setState],
);
const updateNWCInfo = useCallback(
(info: any) => {
setState((prev) => Logic.updateNWCInfo(prev, info));
},
[setState],
);
const disconnectNWC = useCallback(() => {
setState((prev) => Logic.disconnectNWC(prev));
}, [setState]);
return {
state,
isTemporary,
@@ -371,5 +396,9 @@ export const useGrimoire = () => {
switchToTemporary,
applyTemporaryToPersistent,
discardTemporary,
setNWCConnection,
updateNWCBalance,
updateNWCInfo,
disconnectNWC,
};
};

View File

@@ -12,9 +12,13 @@ import db from "@/services/db";
* - Pubkey changes while a fetch is in progress
*
* @param pubkey - The user's public key (hex)
* @param relayHints - Optional relay URLs to try fetching from
* @returns ProfileContent or undefined if loading/not found
*/
export function useProfile(pubkey?: string): ProfileContent | undefined {
export function useProfile(
pubkey?: string,
relayHints?: string[],
): ProfileContent | undefined {
const [profile, setProfile] = useState<ProfileContent | undefined>();
const abortControllerRef = useRef<AbortController | null>(null);
@@ -37,8 +41,12 @@ export function useProfile(pubkey?: string): ProfileContent | undefined {
}
});
// Fetch from network
const sub = profileLoader({ kind: kinds.Metadata, pubkey }).subscribe({
// Fetch from network with optional relay hints
const sub = profileLoader({
kind: kinds.Metadata,
pubkey,
...(relayHints && relayHints.length > 0 && { relays: relayHints }),
}).subscribe({
next: async (fetchedEvent) => {
if (controller.signal.aborted) return;
if (!fetchedEvent || !fetchedEvent.content) return;
@@ -77,7 +85,7 @@ export function useProfile(pubkey?: string): ProfileContent | undefined {
controller.abort();
sub.unsubscribe();
};
}, [pubkey]);
}, [pubkey, relayHints]);
return profile;
}

149
src/hooks/useWallet.ts Normal file
View File

@@ -0,0 +1,149 @@
/**
* useWallet Hook
*
* Provides access to the NWC wallet throughout the application.
* Fully reactive using observables - balance updates automatically via use$()
*
* @example
* ```tsx
* function MyComponent() {
* const { wallet, balance, payInvoice, makeInvoice } = useWallet();
*
* async function handlePay() {
* if (!wallet) return;
* await payInvoice("lnbc...");
* // Balance automatically updates via notifications!
* }
*
* return <div>Balance: {balance ? Math.floor(balance / 1000) : 0} sats</div>;
* }
* ```
*/
import { useEffect, useState } from "react";
import { use$ } from "applesauce-react/hooks";
import { useGrimoire } from "@/core/state";
import {
getWallet,
restoreWallet,
clearWallet as clearWalletService,
refreshBalance as refreshBalanceService,
balance$,
} from "@/services/nwc";
import type { WalletConnect } from "applesauce-wallet-connect";
export function useWallet() {
const { state } = useGrimoire();
const nwcConnection = state.nwcConnection;
const [wallet, setWallet] = useState<WalletConnect | null>(getWallet());
// Subscribe to balance updates from observable (fully reactive!)
const balance = use$(balance$);
// Initialize wallet on mount if connection exists but no wallet instance
useEffect(() => {
if (nwcConnection && !wallet) {
console.log("[useWallet] Restoring wallet from saved connection");
const restoredWallet = restoreWallet(nwcConnection);
setWallet(restoredWallet);
// Fetch initial balance
refreshBalanceService();
}
}, [nwcConnection, wallet]);
// Update local wallet ref when connection changes
useEffect(() => {
const currentWallet = getWallet();
if (currentWallet !== wallet) {
setWallet(currentWallet);
}
}, [nwcConnection, wallet]);
/**
* Pay a BOLT11 invoice
* Balance will auto-update via notification subscription
*/
async function payInvoice(invoice: string, amount?: number) {
if (!wallet) throw new Error("No wallet connected");
const result = await wallet.payInvoice(invoice, amount);
// Balance will update automatically via notifications
// But we can also refresh immediately for instant feedback
await refreshBalanceService();
return result;
}
/**
* Generate a new invoice
*/
async function makeInvoice(
amount: number,
options?: {
description?: string;
description_hash?: string;
expiry?: number;
},
) {
if (!wallet) throw new Error("No wallet connected");
return await wallet.makeInvoice(amount, options);
}
/**
* Get wallet info (capabilities, alias, etc.)
*/
async function getInfo() {
if (!wallet) throw new Error("No wallet connected");
return await wallet.getInfo();
}
/**
* Get current balance
*/
async function getBalance() {
if (!wallet) throw new Error("No wallet connected");
const result = await wallet.getBalance();
return result.balance;
}
/**
* Manually refresh the balance
*/
async function refreshBalance() {
return await refreshBalanceService();
}
/**
* Disconnect the wallet
*/
function disconnect() {
clearWalletService();
setWallet(null);
}
return {
/** The wallet instance (null if not connected) */
wallet,
/** Current balance in millisats (auto-updates via observable!) */
balance,
/** Whether a wallet is connected */
isConnected: !!wallet,
/** Pay a BOLT11 invoice */
payInvoice,
/** Generate a new invoice */
makeInvoice,
/** Get wallet information */
getInfo,
/** Get current balance */
getBalance,
/** Manually refresh balance */
refreshBalance,
/** Disconnect wallet */
disconnect,
};
}

150
src/services/nwc.ts Normal file
View File

@@ -0,0 +1,150 @@
/**
* NWC (Nostr Wallet Connect) Service
*
* Provides a singleton WalletConnect instance for the application using
* applesauce-wallet-connect for NIP-47 Lightning wallet integration.
*
* Features:
* - Maintains persistent wallet connection across app lifetime
* - Subscribes to NIP-47 notifications (kind 23197) for balance updates
* - Fully reactive using RxJS observables (no polling!)
* - Components use use$() to reactively subscribe to balance changes
*/
import { WalletConnect } from "applesauce-wallet-connect";
import type { NWCConnection } from "@/types/app";
import pool from "./relay-pool";
import { BehaviorSubject, Subscription } from "rxjs";
// Set the pool for wallet connect to use
WalletConnect.pool = pool;
let walletInstance: WalletConnect | null = null;
let notificationSubscription: Subscription | null = null;
/**
* Observable for wallet balance updates
* Components can subscribe to this for real-time balance changes using use$()
*/
export const balance$ = new BehaviorSubject<number | undefined>(undefined);
/**
* Helper to convert hex string to Uint8Array
*/
function hexToBytes(hex: string): Uint8Array {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
/**
* Subscribe to wallet notifications (NIP-47 kind 23197)
* This enables real-time balance updates when transactions occur
*/
function subscribeToNotifications(wallet: WalletConnect) {
// Clean up existing subscription
if (notificationSubscription) {
notificationSubscription.unsubscribe();
}
console.log("[NWC] Subscribing to wallet notifications");
// Subscribe to the wallet's notifications$ observable
// This receives events like payment_received, payment_sent, etc.
notificationSubscription = wallet.notifications$.subscribe({
next: (notification) => {
console.log("[NWC] Notification received:", notification);
// When we get a notification, refresh the balance
// The notification types include: payment_received, payment_sent, etc.
wallet
.getBalance()
.then((result) => {
const newBalance = result.balance;
if (balance$.value !== newBalance) {
balance$.next(newBalance);
console.log("[NWC] Balance updated from notification:", newBalance);
}
})
.catch((error) => {
console.error(
"[NWC] Failed to fetch balance after notification:",
error,
);
});
},
error: (error) => {
console.error("[NWC] Notification subscription error:", error);
},
});
}
/**
* Creates a new WalletConnect instance from a connection string
* Automatically subscribes to notifications for balance updates
*/
export function createWalletFromURI(connectionString: string): WalletConnect {
walletInstance = WalletConnect.fromConnectURI(connectionString);
subscribeToNotifications(walletInstance);
return walletInstance;
}
/**
* Restores a wallet from saved connection data
* Used on app startup to reconnect to a previously connected wallet
*/
export function restoreWallet(connection: NWCConnection): WalletConnect {
walletInstance = new WalletConnect({
service: connection.service,
relays: connection.relays,
secret: hexToBytes(connection.secret),
});
// Set initial balance from cache
if (connection.balance !== undefined) {
balance$.next(connection.balance);
}
subscribeToNotifications(walletInstance);
return walletInstance;
}
/**
* Gets the current wallet instance
*/
export function getWallet(): WalletConnect | null {
return walletInstance;
}
/**
* Clears the current wallet instance and stops notifications
*/
export function clearWallet(): void {
if (notificationSubscription) {
notificationSubscription.unsubscribe();
notificationSubscription = null;
}
walletInstance = null;
balance$.next(undefined);
}
/**
* Manually refresh the balance from the wallet
* Useful for initial load or manual refresh button
*/
export async function refreshBalance(): Promise<number | undefined> {
if (!walletInstance) return undefined;
try {
const result = await walletInstance.getBalance();
const newBalance = result.balance;
balance$.next(newBalance);
return newBalance;
} catch (error) {
console.error("[NWC] Failed to refresh balance:", error);
return undefined;
}
}

View File

@@ -79,6 +79,30 @@ export interface RelayInfo {
write: boolean;
}
/**
* Nostr Wallet Connect (NIP-47) wallet connection
*/
export interface NWCConnection {
/** The wallet service's public key */
service: string;
/** Relay URL(s) for communication */
relays: string[];
/** Shared secret for encryption */
secret: string;
/** Optional lightning address (lud16) */
lud16?: string;
/** Optional cached balance in millisats */
balance?: number;
/** Optional wallet info */
info?: {
alias?: string;
methods?: string[];
notifications?: string[];
};
/** Last connection time */
lastConnected?: number;
}
export interface GrimoireState {
__version: number; // Schema version for migrations
windows: Record<string, WindowInstance>;
@@ -110,4 +134,5 @@ export interface GrimoireState {
localId?: string; // Local DB ID if saved to library
isPublished?: boolean; // Whether it has been published to Nostr
};
nwcConnection?: NWCConnection;
}