feat: Add NIP-47 Nostr Wallet Connect integration

Implements comprehensive wallet management system with NWC support:

- Install applesauce-wallet-connect for NIP-47 protocol handling
- Create WalletManager singleton service for connection management
  - Persistent localStorage storage for wallet connections
  - Support for multiple wallet connections with active wallet selection
  - Balance queries, invoice payments, and wallet info retrieval
- Add wallet command and parser for CLI-style interaction
  - `wallet` - Open wallet manager
  - `wallet connect <uri>` - Add NWC connection
  - Support for custom wallet names via --name flag
- Create WalletViewer component with full wallet management UI
  - List all wallet connections with balances
  - Add/remove wallet connections
  - Set active wallet
  - Visual connection status indicators
- Wire into window rendering and command system
- Add wallet icon to command icons

This provides the foundation for future payment features (zaps, nutzaps, etc.)
as discussed in the wallet/payment architecture planning.
This commit is contained in:
Claude
2026-01-14 18:24:24 +00:00
parent 5fa2a1c9b8
commit 62ce34d9c5
9 changed files with 821 additions and 0 deletions

17
package-lock.json generated
View File

@@ -41,6 +41,7 @@
"applesauce-react": "^5.0.1",
"applesauce-relay": "^5.0.0",
"applesauce-signers": "^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",
@@ -5706,6 +5707,22 @@
"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",

View File

@@ -49,6 +49,7 @@
"applesauce-react": "^5.0.1",
"applesauce-relay": "^5.0.0",
"applesauce-signers": "^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",

View File

@@ -0,0 +1,358 @@
import { useState, useEffect } from "react";
import { toast } from "sonner";
import {
Wallet,
Plus,
Trash2,
Loader2,
Check,
Info,
AlertCircle,
Zap,
} from "lucide-react";
import walletManager from "@/services/wallet";
import type {
WalletConnectionInfo,
WalletBalance,
WalletInfo,
} from "@/services/wallet";
import { use$ } from "applesauce-react/hooks";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "./ui/dialog";
export interface WalletViewerProps {
action: "view" | "connect";
connectionURI?: string;
name?: string;
}
/**
* WalletViewer - Manage NIP-47 Nostr Wallet Connect connections
*
* Features:
* - View all wallet connections
* - Add new NWC connections
* - View wallet balances
* - Set active wallet
* - Remove connections
*/
function WalletViewer({ action, connectionURI, name }: WalletViewerProps) {
const [connections, setConnections] = useState<WalletConnectionInfo[]>([]);
const activeWalletId = use$(walletManager.activeWalletId);
const [isAddDialogOpen, setIsAddDialogOpen] = useState(action === "connect");
const [newConnectionURI, setNewConnectionURI] = useState(connectionURI || "");
const [newConnectionName, setNewConnectionName] = useState(name || "");
const [isAddingConnection, setIsAddingConnection] = useState(false);
// Load connections on mount and when action changes
useEffect(() => {
loadConnections();
}, []);
// Auto-add connection if URI provided via command
useEffect(() => {
if (action === "connect" && connectionURI && !isAddingConnection) {
void handleAddConnection(connectionURI, name);
}
}, [action, connectionURI, name]);
const loadConnections = () => {
setConnections(walletManager.getConnections());
};
const handleAddConnection = async (uri: string, customName?: string) => {
if (!uri) {
toast.error("Connection URI required");
return;
}
setIsAddingConnection(true);
try {
await walletManager.addConnectionFromURI(uri, customName);
loadConnections();
toast.success("Wallet connected successfully");
setIsAddDialogOpen(false);
setNewConnectionURI("");
setNewConnectionName("");
} catch (error) {
console.error("Failed to add wallet connection:", error);
toast.error(
`Failed to connect wallet: ${error instanceof Error ? error.message : "Unknown error"}`,
);
} finally {
setIsAddingConnection(false);
}
};
const handleRemoveConnection = async (id: string) => {
try {
walletManager.removeConnection(id);
loadConnections();
toast.success("Wallet connection removed");
} catch (error) {
console.error("Failed to remove wallet connection:", error);
toast.error(
`Failed to remove connection: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
};
const handleSetActiveWallet = (id: string) => {
try {
walletManager.setActiveWallet(id);
toast.success("Active wallet updated");
} catch (error) {
console.error("Failed to set active wallet:", error);
toast.error(
`Failed to set active wallet: ${error instanceof Error ? error.message : "Unknown error"}`,
);
}
};
return (
<div className="h-full w-full flex flex-col bg-background text-foreground">
{/* Header */}
<div className="border-b border-border px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-2">
<Wallet className="size-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Wallet Manager</h1>
</div>
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Plus className="size-4 mr-2" />
Add Connection
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Wallet Connection</DialogTitle>
<DialogDescription>
Connect a NIP-47 Nostr Wallet Connect wallet. Get a connection
URI from your wallet provider (e.g., Alby, Mutiny, etc.)
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">
Connection URI (required)
</label>
<Input
placeholder="nostr+walletconnect://..."
value={newConnectionURI}
onChange={(e) => setNewConnectionURI(e.target.value)}
disabled={isAddingConnection}
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">
Wallet Name (optional)
</label>
<Input
placeholder="My Wallet"
value={newConnectionName}
onChange={(e) => setNewConnectionName(e.target.value)}
disabled={isAddingConnection}
/>
</div>
</div>
<DialogFooter>
<Button
onClick={() =>
handleAddConnection(newConnectionURI, newConnectionName)
}
disabled={isAddingConnection || !newConnectionURI}
>
{isAddingConnection && (
<Loader2 className="size-4 mr-2 animate-spin" />
)}
Connect
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
{/* Wallet List */}
<div className="flex-1 overflow-y-auto">
{connections.length === 0 && (
<div className="text-center text-muted-foreground font-mono text-sm p-8">
<Wallet className="size-12 mx-auto mb-4 opacity-50" />
<p className="mb-2">No wallet connections</p>
<p className="text-xs">
Add a NIP-47 wallet connection to get started
</p>
</div>
)}
{connections.map((connection) => (
<WalletCard
key={connection.id}
connection={connection}
isActive={activeWalletId === connection.id}
onSetActive={handleSetActiveWallet}
onRemove={handleRemoveConnection}
/>
))}
</div>
</div>
);
}
interface WalletCardProps {
connection: WalletConnectionInfo;
isActive: boolean;
onSetActive: (id: string) => void;
onRemove: (id: string) => void;
}
function WalletCard({
connection,
isActive,
onSetActive,
onRemove,
}: WalletCardProps) {
const [balance, setBalance] = useState<WalletBalance | null>(null);
const [info, setInfo] = useState<WalletInfo | null>(null);
const [isLoadingBalance, setIsLoadingBalance] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadWalletData();
}, [connection.id]);
const loadWalletData = async () => {
setIsLoadingBalance(true);
setError(null);
try {
const [balanceData, infoData] = await Promise.all([
walletManager.getBalance(connection.id),
walletManager.getInfo(connection.id),
]);
setBalance(balanceData);
setInfo(infoData);
} catch (err) {
console.error("Failed to load wallet data:", err);
setError(
err instanceof Error ? err.message : "Failed to load wallet data",
);
} finally {
setIsLoadingBalance(false);
}
};
const formatBalance = (millisats: number) => {
const sats = Math.floor(millisats / 1000);
return sats.toLocaleString();
};
return (
<div className="border-b border-border">
<div className="px-4 py-3 flex flex-col gap-3">
{/* Main Row */}
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h3 className="font-semibold text-sm truncate">
{connection.name}
</h3>
{isActive && (
<Tooltip>
<TooltipTrigger asChild>
<div className="cursor-help">
<Check className="size-4 text-green-500" />
</div>
</TooltipTrigger>
<TooltipContent>
<p>Active Wallet</p>
</TooltipContent>
</Tooltip>
)}
</div>
{/* Relays */}
<div className="text-xs text-muted-foreground font-mono truncate mb-2">
{connection.relays.join(", ")}
</div>
{/* Balance */}
<div className="flex items-center gap-2">
{isLoadingBalance ? (
<div className="flex items-center gap-2 text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span className="text-sm">Loading balance...</span>
</div>
) : error ? (
<div className="flex items-center gap-2 text-red-500">
<AlertCircle className="size-4" />
<span className="text-sm">{error}</span>
</div>
) : balance ? (
<div className="flex items-center gap-2">
<Zap className="size-4 text-yellow-500" />
<span className="font-mono font-semibold">
{formatBalance(balance.balance)} sats
</span>
</div>
) : null}
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2 flex-shrink-0">
{info && (
<Tooltip>
<TooltipTrigger asChild>
<button className="text-muted-foreground hover:text-foreground transition-colors">
<Info className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<div className="text-xs space-y-1">
{info.alias && <div>Alias: {info.alias}</div>}
{info.network && <div>Network: {info.network}</div>}
<div>Methods: {info.methods.join(", ")}</div>
</div>
</TooltipContent>
</Tooltip>
)}
{!isActive && (
<Button
size="sm"
variant="ghost"
onClick={() => onSetActive(connection.id)}
>
Set Active
</Button>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={() => onRemove(connection.id)}
className="text-muted-foreground hover:text-red-500 transition-colors"
>
<Trash2 className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
<p>Remove Connection</p>
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
</div>
);
}
export default WalletViewer;

View File

@@ -42,6 +42,7 @@ const SpellbooksViewer = lazy(() =>
const BlossomViewer = lazy(() =>
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
);
const WalletViewer = lazy(() => import("./WalletViewer"));
// Loading fallback component
function ViewerLoading() {
@@ -210,6 +211,15 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
/>
);
break;
case "wallet":
content = (
<WalletViewer
action={window.props.action}
connectionURI={window.props.connectionURI}
name={window.props.name}
/>
);
break;
default:
content = (
<div className="p-4 text-muted-foreground">

View File

@@ -15,6 +15,7 @@ import {
Bug,
Wifi,
MessageSquare,
Wallet,
type LucideIcon,
} from "lucide-react";
@@ -99,6 +100,10 @@ export const COMMAND_ICONS: Record<string, CommandIcon> = {
icon: Wifi,
description: "View relay pool connection and authentication status",
},
wallet: {
icon: Wallet,
description: "Manage Nostr Wallet Connect connections",
},
};
export function getCommandIcon(command: string): LucideIcon {

55
src/lib/wallet-parser.ts Normal file
View File

@@ -0,0 +1,55 @@
export interface ParsedWalletCommand {
action: "view" | "connect";
connectionURI?: string;
name?: string;
}
/**
* Parse WALLET command arguments
* Supports:
* - wallet -> Open wallet manager
* - wallet connect <uri> -> Add NWC connection
* - wallet connect <uri> --name "..." -> Add NWC connection with custom name
*/
export function parseWalletCommand(args: string[]): ParsedWalletCommand {
// No args = open wallet manager
if (args.length === 0) {
return {
action: "view",
};
}
const subcommand = args[0];
if (subcommand === "connect") {
const uri = args[1];
if (!uri) {
throw new Error("Connection URI required. Usage: wallet connect <uri>");
}
// Validate URI format
if (!uri.startsWith("nostr+walletconnect://")) {
throw new Error(
"Invalid connection URI. Must start with 'nostr+walletconnect://'",
);
}
// Check for --name flag
let name: string | undefined;
const nameIndex = args.indexOf("--name");
if (nameIndex !== -1 && args[nameIndex + 1]) {
name = args[nameIndex + 1];
}
return {
action: "connect",
connectionURI: uri,
name,
};
}
throw new Error(
`Unknown wallet subcommand: ${subcommand}. Available: connect`,
);
}

343
src/services/wallet.ts Normal file
View File

@@ -0,0 +1,343 @@
import { WalletConnect } from "applesauce-wallet-connect";
import { BehaviorSubject } from "rxjs";
export type WalletConnectionInfo = {
id: string; // Unique identifier
name: string; // User-friendly name
pubkey: string; // Service pubkey
relays: string[]; // Relay URLs
secret: string; // Secret key (hex)
createdAt: number; // Timestamp
};
export type WalletBalance = {
balance: number; // millisatoshis
};
export type WalletInfo = {
alias?: string;
color?: string;
pubkey?: string;
network?: string;
block_height?: number;
block_hash?: string;
methods: string[];
};
/**
* WalletManager - Singleton service for managing Nostr Wallet Connect connections
*
* Handles NIP-47 wallet connections with persistence to localStorage.
* Follows the singleton pattern like EventStore and RelayPool.
*/
class WalletManager {
private connections = new Map<string, WalletConnect>();
private connectionInfo = new Map<string, WalletConnectionInfo>();
private activeWalletId$ = new BehaviorSubject<string | undefined>(undefined);
private storageKey = "grimoire_wallet_connections_v1";
constructor() {
this.loadFromStorage();
}
/**
* Load wallet connections from localStorage
*/
private loadFromStorage() {
try {
const stored = localStorage.getItem(this.storageKey);
if (!stored) return;
const data = JSON.parse(stored);
const connections: WalletConnectionInfo[] = data.connections || [];
const activeId: string | undefined = data.activeId;
// Restore wallet connections
for (const info of connections) {
try {
// Convert hex secret to Uint8Array
const secretBytes = new Uint8Array(
info.secret.match(/.{1,2}/g)!.map((byte) => parseInt(byte, 16)),
);
const wallet = new WalletConnect({
secret: secretBytes,
relays: info.relays,
service: info.pubkey,
});
this.connections.set(info.id, wallet);
this.connectionInfo.set(info.id, info);
} catch (error) {
console.error(
`[WalletManager] Failed to restore connection ${info.id}:`,
error,
);
}
}
// Restore active wallet
if (activeId && this.connections.has(activeId)) {
this.activeWalletId$.next(activeId);
}
console.log(
`[WalletManager] Restored ${this.connections.size} wallet connection(s)`,
);
} catch (error) {
console.error("[WalletManager] Failed to load from storage:", error);
}
}
/**
* Save wallet connections to localStorage
*/
private saveToStorage() {
try {
const connections = Array.from(this.connectionInfo.values());
const activeId = this.activeWalletId$.value;
const data = {
connections,
activeId,
};
localStorage.setItem(this.storageKey, JSON.stringify(data));
} catch (error) {
console.error("[WalletManager] Failed to save to storage:", error);
}
}
/**
* Add a new wallet connection from a connection URI
* Format: nostr+walletconnect://relay?secret=xxx&pubkey=xxx&relay=xxx
*/
async addConnectionFromURI(uri: string, name?: string): Promise<string> {
const wallet = WalletConnect.fromConnectURI(uri);
// Wait for service to be available (with 5 second timeout)
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
try {
await wallet.waitForService(controller.signal);
} catch (error) {
clearTimeout(timeoutId);
// Service might not be online, but we can still save the connection
console.warn("[WalletManager] Service not available:", error);
}
clearTimeout(timeoutId);
// Generate unique ID
const id = crypto.randomUUID();
// Get wallet info to use as default name
let displayName = name;
if (!displayName) {
try {
const info = await wallet.getInfo();
displayName = info.alias || `Wallet ${this.connections.size + 1}`;
} catch {
displayName = `Wallet ${this.connections.size + 1}`;
}
}
// Convert secret Uint8Array to hex string for storage
const secretHex = Array.from(wallet.secret)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
// Store connection info
const connectionInfo: WalletConnectionInfo = {
id,
name: displayName,
pubkey: wallet.service || "",
relays: wallet.relays,
secret: secretHex,
createdAt: Date.now(),
};
this.connections.set(id, wallet);
this.connectionInfo.set(id, connectionInfo);
// Set as active if it's the first wallet
if (this.connections.size === 1) {
this.activeWalletId$.next(id);
}
this.saveToStorage();
console.log(`[WalletManager] Added connection: ${displayName} (${id})`);
return id;
}
/**
* Remove a wallet connection
*/
removeConnection(id: string) {
const info = this.connectionInfo.get(id);
if (!info) {
throw new Error(`Connection ${id} not found`);
}
this.connections.delete(id);
this.connectionInfo.delete(id);
// Clear active wallet if it was the removed one
if (this.activeWalletId$.value === id) {
// Set to first available wallet or undefined
const firstId = Array.from(this.connections.keys())[0];
this.activeWalletId$.next(firstId);
}
this.saveToStorage();
console.log(`[WalletManager] Removed connection: ${info.name} (${id})`);
}
/**
* Set the active wallet
*/
setActiveWallet(id: string) {
if (!this.connections.has(id)) {
throw new Error(`Connection ${id} not found`);
}
this.activeWalletId$.next(id);
this.saveToStorage();
}
/**
* Get the active wallet connection
*/
getActiveWallet(): WalletConnect | undefined {
const activeId = this.activeWalletId$.value;
return activeId ? this.connections.get(activeId) : undefined;
}
/**
* Get active wallet ID observable
*/
get activeWalletId() {
return this.activeWalletId$.asObservable();
}
/**
* Get all wallet connections info
*/
getConnections(): WalletConnectionInfo[] {
return Array.from(this.connectionInfo.values());
}
/**
* Get wallet connection by ID
*/
getConnection(id: string): WalletConnect | undefined {
return this.connections.get(id);
}
/**
* Get wallet connection info by ID
*/
getConnectionInfo(id: string): WalletConnectionInfo | undefined {
return this.connectionInfo.get(id);
}
/**
* Get wallet balance
*/
async getBalance(id?: string): Promise<WalletBalance> {
const wallet = id ? this.connections.get(id) : this.getActiveWallet();
if (!wallet) {
throw new Error("No wallet connection available");
}
const result = await wallet.getBalance();
return { balance: result.balance };
}
/**
* Get wallet info
*/
async getInfo(id?: string): Promise<WalletInfo> {
const wallet = id ? this.connections.get(id) : this.getActiveWallet();
if (!wallet) {
throw new Error("No wallet connection available");
}
return await wallet.getInfo();
}
/**
* Pay a Lightning invoice
*/
async payInvoice(
invoice: string,
id?: string,
): Promise<{ preimage: string }> {
const wallet = id ? this.connections.get(id) : this.getActiveWallet();
if (!wallet) {
throw new Error("No wallet connection available");
}
const result = await wallet.payInvoice(invoice);
return { preimage: result.preimage };
}
/**
* Create an invoice
*/
async makeInvoice(
amount: number,
description?: string,
id?: string,
): Promise<{ invoice: string; payment_hash: string }> {
const wallet = id ? this.connections.get(id) : this.getActiveWallet();
if (!wallet) {
throw new Error("No wallet connection available");
}
const result = await wallet.makeInvoice(amount, { description });
return {
invoice: result.invoice || "",
payment_hash: result.payment_hash || "",
};
}
/**
* List transactions
*/
async listTransactions(id?: string): Promise<any[]> {
const wallet = id ? this.connections.get(id) : this.getActiveWallet();
if (!wallet) {
throw new Error("No wallet connection available");
}
const result = await wallet.listTransactions();
return result.transactions || [];
}
/**
* Lookup invoice status
*/
async lookupInvoice(
invoice: string,
id?: string,
): Promise<{ paid: boolean; preimage?: string }> {
const wallet = id ? this.connections.get(id) : this.getActiveWallet();
if (!wallet) {
throw new Error("No wallet connection available");
}
const result = await wallet.lookupInvoice(undefined, invoice);
// The result is a Transaction type, need to determine if it's paid
// A transaction with preimage is considered paid
return {
paid: !!result.preimage,
preimage: result.preimage,
};
}
}
// Export singleton instance
const walletManager = new WalletManager();
export default walletManager;

View File

@@ -20,6 +20,7 @@ export type AppId =
| "spells"
| "spellbooks"
| "blossom"
| "wallet"
| "win";
export interface WindowInstance {

View File

@@ -7,6 +7,7 @@ import { parseRelayCommand } from "@/lib/relay-parser";
import { resolveNip05Batch } from "@/lib/nip05";
import { parseChatCommand } from "@/lib/chat-parser";
import { parseBlossomCommand } from "@/lib/blossom-parser";
import { parseWalletCommand } from "@/lib/wallet-parser";
export interface ManPageEntry {
name: string;
@@ -575,4 +576,34 @@ export const manPages: Record<string, ManPageEntry> = {
},
defaultProps: { subcommand: "servers" },
},
wallet: {
name: "wallet",
section: "1",
synopsis: "wallet [command] [options]",
description:
"Manage Nostr Wallet Connect (NIP-47) connections for Lightning payments. View wallet balances, manage connections, and configure payment settings.",
options: [
{
flag: "connect <uri>",
description:
"Add a new NWC connection using a connection URI (nostr+walletconnect://...)",
},
{
flag: "--name <name>",
description: "Set a custom name for the wallet connection",
},
],
examples: [
"wallet Open wallet manager",
"wallet connect nostr+walletconnect://... Add NWC connection",
'wallet connect nwc://... --name "Alby" Add with custom name',
],
seeAlso: ["profile"],
appId: "wallet",
category: "System",
argParser: (args: string[]) => {
return parseWalletCommand(args);
},
defaultProps: { action: "view" },
},
};