mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-16 18:37:14 +02:00
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:
17
package-lock.json
generated
17
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
358
src/components/WalletViewer.tsx
Normal file
358
src/components/WalletViewer.tsx
Normal 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;
|
||||
@@ -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">
|
||||
|
||||
@@ -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
55
src/lib/wallet-parser.ts
Normal 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
343
src/services/wallet.ts
Normal 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;
|
||||
@@ -20,6 +20,7 @@ export type AppId =
|
||||
| "spells"
|
||||
| "spellbooks"
|
||||
| "blossom"
|
||||
| "wallet"
|
||||
| "win";
|
||||
|
||||
export interface WindowInstance {
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user