feat: add NIP-60 Cashu wallet viewer with wallet subcommand

Implements the "wallet nip-61" subcommand for viewing NIP-60 Cashu wallets:
- Add wallet-parser.ts for subcommand parsing (nwc, nip-61, cashu, etc.)
- Create shared wallet UI components for reuse across wallet types
- Add useNip61Wallet hook for reactive wallet state via applesauce-wallet
- Create Nip61WalletViewer showing balance, mints breakdown, and history
- Update WindowRenderer to route wallet subcommands appropriately

The viewer supports:
- Unlocking encrypted wallet content
- Displaying total balance and per-mint breakdown
- Viewing transaction history (placeholders for send/receive)
This commit is contained in:
Claude
2026-01-23 23:13:26 +00:00
parent 65aff7cc87
commit 497ec07bd8
12 changed files with 1108 additions and 6 deletions

View File

@@ -0,0 +1,307 @@
/**
* Nip61WalletViewer Component
*
* Displays NIP-60 Cashu wallet information:
* - Wallet balance (total and per-mint)
* - Transaction history
* - Placeholder send/receive buttons
*/
import { useState, useMemo, useCallback } from "react";
import { Send, Download, RefreshCw, Settings, Coins } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
WalletBalance,
WalletHeader,
WalletHistoryList,
TransactionRow,
NoWalletView,
WalletLockedView,
type HistoryItem,
} from "@/components/wallet";
import { useNip61Wallet } from "@/hooks/useNip61Wallet";
import { useGrimoire } from "@/core/state";
import { useAccount } from "@/hooks/useAccount";
import type { WalletHistory } from "applesauce-wallet/casts";
/**
* Component to display balance breakdown by mint
*/
function MintBalanceBreakdown({
balance,
blurred,
}: {
balance: Record<string, number> | undefined;
blurred: boolean;
}) {
if (!balance || Object.keys(balance).length === 0) {
return null;
}
return (
<div className="px-4 pb-4">
<div className="max-w-md mx-auto">
<div className="text-xs text-muted-foreground mb-2 flex items-center gap-1">
<Coins className="size-3" />
Balance by Mint
</div>
<div className="space-y-1">
{Object.entries(balance).map(([mint, amount]) => (
<div
key={mint}
className="flex justify-between items-center py-1.5 px-2 bg-muted/50 rounded text-sm"
>
<span className="font-mono text-xs truncate max-w-[200px] text-muted-foreground">
{new URL(mint).hostname}
</span>
<span className="font-mono font-medium">
{blurred ? "✦✦✦" : amount.toLocaleString()} sats
</span>
</div>
))}
</div>
</div>
</div>
);
}
/**
* Transform WalletHistory into HistoryItem for the list
*/
function historyToItem(entry: WalletHistory): HistoryItem {
return {
id: entry.id,
timestamp: entry.event.created_at,
data: entry,
};
}
export default function Nip61WalletViewer() {
const { state, toggleWalletBalancesBlur } = useGrimoire();
const { isLoggedIn } = useAccount();
const {
hasWallet,
isUnlocked,
balance,
totalBalance,
history,
mints,
unlock,
unlocking,
error,
} = useNip61Wallet();
const [refreshing, setRefreshing] = useState(false);
// Transform history for the list component
const historyItems = useMemo(() => {
if (!history) return [];
return history.map(historyToItem);
}, [history]);
// Refresh wallet data
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
// Re-unlock to refresh encrypted content
await unlock();
toast.success("Wallet refreshed");
} catch (err) {
console.error("Failed to refresh wallet:", err);
toast.error("Failed to refresh wallet");
} finally {
setRefreshing(false);
}
}, [unlock]);
// Render history entry
const renderHistoryEntry = useCallback(
(item: HistoryItem) => {
const entry = item.data as WalletHistory;
// If not unlocked, show placeholder
if (!entry.unlocked) {
return (
<TransactionRow
key={entry.id}
direction="in"
amount={0}
blurred={true}
label={
<span className="text-sm text-muted-foreground">Locked</span>
}
/>
);
}
// Get meta synchronously if available
// Note: In a real implementation, we'd need to handle the observable
// For now, we'll show a simplified view
return (
<TransactionRow
key={entry.id}
direction="in" // TODO: Get from meta$
amount={0} // TODO: Get from meta$
blurred={state.walletBalancesBlurred ?? false}
label={
<span className="text-sm">
{entry.event.created_at
? new Date(entry.event.created_at * 1000).toLocaleTimeString()
: "Transaction"}
</span>
}
/>
);
},
[state.walletBalancesBlurred],
);
// Not logged in
if (!isLoggedIn) {
return (
<NoWalletView
title="Not Logged In"
message="Log in with a Nostr account to access your Cashu wallet."
/>
);
}
// No wallet found
if (!hasWallet) {
return (
<NoWalletView
title="No Cashu Wallet"
message="No NIP-60 Cashu wallet found for your account. Wallet creation is not yet supported in Grimoire."
/>
);
}
// Wallet locked
if (!isUnlocked) {
return (
<WalletLockedView
message="Your Cashu wallet is encrypted. Unlock it to view your balance and history."
loading={unlocking}
onUnlock={unlock}
/>
);
}
// Determine wallet status
const walletStatus = unlocking || refreshing ? "loading" : "connected";
return (
<div className="h-full w-full flex flex-col bg-background text-foreground">
{/* Header */}
<WalletHeader
name="Cashu Wallet"
status={walletStatus}
info={
mints && mints.length > 0 ? (
<span className="text-muted-foreground ml-2">
{mints.length} mint{mints.length !== 1 ? "s" : ""}
</span>
) : undefined
}
actions={
<div className="flex items-center gap-3">
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={handleRefresh}
disabled={refreshing}
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
aria-label="Refresh wallet"
>
<RefreshCw
className={`size-3 ${refreshing ? "animate-spin" : ""}`}
/>
</button>
</TooltipTrigger>
<TooltipContent>Refresh Wallet</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors opacity-50 cursor-not-allowed"
aria-label="Settings (coming soon)"
disabled
>
<Settings className="size-3" />
</button>
</TooltipTrigger>
<TooltipContent>Settings (coming soon)</TooltipContent>
</Tooltip>
</div>
}
/>
{/* Error display */}
{error && (
<div className="px-4 py-2 bg-destructive/10 text-destructive text-sm">
{error}
</div>
)}
{/* Balance */}
<WalletBalance
balance={totalBalance}
blurred={state.walletBalancesBlurred ?? false}
onToggleBlur={toggleWalletBalancesBlur}
label="sats"
/>
{/* Balance by mint */}
<MintBalanceBreakdown
balance={balance}
blurred={state.walletBalancesBlurred ?? false}
/>
{/* Send / Receive Buttons (Placeholders) */}
<div className="px-4 pb-3">
<div className="max-w-md mx-auto grid grid-cols-2 gap-3">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline" disabled className="opacity-50">
<Download className="mr-2 size-4" />
Receive
</Button>
</TooltipTrigger>
<TooltipContent>Coming soon</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="default" disabled className="opacity-50">
<Send className="mr-2 size-4" />
Send
</Button>
</TooltipTrigger>
<TooltipContent>Coming soon</TooltipContent>
</Tooltip>
</div>
</div>
{/* Transaction History */}
<div className="flex-1 overflow-hidden flex justify-center">
<div className="w-full max-w-md">
<WalletHistoryList
items={historyItems}
loading={false}
loadFailed={false}
hasMore={false}
emptyMessage="No transaction history"
renderItem={renderHistoryEntry}
/>
</div>
</div>
</div>
);
}

View File

@@ -43,6 +43,7 @@ const BlossomViewer = lazy(() =>
import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })),
);
const WalletViewer = lazy(() => import("./WalletViewer"));
const Nip61WalletViewer = lazy(() => import("./Nip61WalletViewer"));
const ZapWindow = lazy(() =>
import("./ZapWindow").then((m) => ({ default: m.ZapWindow })),
);
@@ -232,9 +233,15 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) {
/>
);
break;
case "wallet":
content = <WalletViewer />;
case "wallet": {
const walletSubcommand = window.props.subcommand || "nwc";
if (walletSubcommand === "nip-61") {
content = <Nip61WalletViewer />;
} else {
content = <WalletViewer />;
}
break;
}
case "zap":
content = (
<ZapWindow

View File

@@ -0,0 +1,58 @@
/**
* TransactionRow Component
*
* Displays a single transaction in a list.
* Shared between NWC and NIP-61 wallet viewers.
*/
import { ReactNode } from "react";
import { ArrowUpRight, ArrowDownLeft } from "lucide-react";
interface TransactionRowProps {
/** Transaction direction */
direction: "in" | "out";
/** Amount in satoshis */
amount: number;
/** Whether amount should be blurred */
blurred?: boolean;
/** Transaction label/description */
label: ReactNode;
/** Click handler */
onClick?: () => void;
}
/**
* Format satoshi amount with locale-aware thousands separator
*/
function formatSats(sats: number): string {
return sats.toLocaleString();
}
export function TransactionRow({
direction,
amount,
blurred = false,
label,
onClick,
}: TransactionRowProps) {
return (
<div
className="flex items-center justify-between border-b border-border px-4 py-2.5 hover:bg-muted/50 transition-colors flex-shrink-0 cursor-pointer"
onClick={onClick}
>
<div className="flex items-center gap-3 min-w-0 flex-1">
{direction === "in" ? (
<ArrowDownLeft className="size-4 text-green-500 flex-shrink-0" />
) : (
<ArrowUpRight className="size-4 text-red-500 flex-shrink-0" />
)}
<div className="min-w-0 flex-1">{label}</div>
</div>
<div className="flex-shrink-0 ml-4">
<p className="text-sm font-semibold font-mono">
{blurred ? "✦✦✦✦" : formatSats(amount)}
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,54 @@
/**
* WalletBalance Component
*
* Displays a large centered balance with privacy blur toggle.
* Shared between NWC and NIP-61 wallet viewers.
*/
import { Eye, EyeOff } from "lucide-react";
interface WalletBalanceProps {
/** Balance in satoshis */
balance: number | undefined;
/** Whether balance is blurred for privacy */
blurred: boolean;
/** Callback to toggle blur */
onToggleBlur: () => void;
/** Optional label shown below balance */
label?: string;
}
/**
* Format satoshi amount with locale-aware thousands separator
*/
function formatSats(sats: number | undefined): string {
if (sats === undefined) return "—";
return sats.toLocaleString();
}
export function WalletBalance({
balance,
blurred,
onToggleBlur,
label,
}: WalletBalanceProps) {
return (
<div className="py-4 flex flex-col items-center justify-center">
<button
onClick={onToggleBlur}
className="text-4xl font-bold font-mono hover:opacity-70 transition-opacity cursor-pointer flex items-center gap-3"
title="Click to toggle privacy blur"
>
<span>{blurred ? "✦✦✦✦✦✦" : formatSats(balance)}</span>
{blurred ? (
<EyeOff className="size-5 text-muted-foreground" />
) : (
<Eye className="size-5 text-muted-foreground" />
)}
</button>
{label && (
<span className="text-sm text-muted-foreground mt-1">{label}</span>
)}
</div>
);
}

View File

@@ -0,0 +1,53 @@
/**
* WalletHeader Component
*
* Displays wallet name, status indicator, and action buttons.
* Shared between NWC and NIP-61 wallet viewers.
*/
import { ReactNode } from "react";
export type WalletStatus = "connected" | "locked" | "disconnected" | "loading";
interface WalletHeaderProps {
/** Wallet name/alias */
name: string;
/** Connection/unlock status */
status: WalletStatus;
/** Action buttons (refresh, settings, disconnect, etc.) */
actions?: ReactNode;
/** Additional info content (dropdown, badges, etc.) */
info?: ReactNode;
}
function StatusIndicator({ status }: { status: WalletStatus }) {
const colors: Record<WalletStatus, string> = {
connected: "bg-green-500",
locked: "bg-yellow-500",
disconnected: "bg-red-500",
loading: "bg-blue-500 animate-pulse",
};
return <div className={`size-1.5 rounded-full ${colors[status]}`} />;
}
export function WalletHeader({
name,
status,
actions,
info,
}: WalletHeaderProps) {
return (
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between">
{/* Left: Wallet Name + Status */}
<div className="flex items-center gap-2">
<span className="font-semibold">{name}</span>
<StatusIndicator status={status} />
{info}
</div>
{/* Right: Actions */}
{actions && <div className="flex items-center gap-3">{actions}</div>}
</div>
);
}

View File

@@ -0,0 +1,209 @@
/**
* WalletHistoryList Component
*
* Virtualized list of wallet transactions/history with day markers.
* Shared between NWC and NIP-61 wallet viewers.
*/
import { ReactNode, useMemo } from "react";
import { Virtuoso } from "react-virtuoso";
import { RefreshCw } from "lucide-react";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
export interface HistoryItem {
/** Unique identifier */
id: string;
/** Unix timestamp in seconds */
timestamp: number;
/** Custom data for rendering */
data: unknown;
}
interface WalletHistoryListProps<T extends HistoryItem> {
/** History items to display */
items: T[];
/** Whether initial load is in progress */
loading: boolean;
/** Whether more items are being loaded */
loadingMore?: boolean;
/** Whether there are more items to load */
hasMore?: boolean;
/** Whether loading failed */
loadFailed?: boolean;
/** Callback to load more items */
onLoadMore?: () => void;
/** Callback to retry loading */
onRetry?: () => void;
/** Render function for each item */
renderItem: (item: T, index: number) => ReactNode;
/** Empty state message */
emptyMessage?: string;
}
/**
* Format timestamp as a readable day marker
*/
function formatDayMarker(timestamp: number): string {
const date = new Date(timestamp * 1000);
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
// Reset time parts for comparison
const dateOnly = new Date(
date.getFullYear(),
date.getMonth(),
date.getDate(),
);
const todayOnly = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate(),
);
const yesterdayOnly = new Date(
yesterday.getFullYear(),
yesterday.getMonth(),
yesterday.getDate(),
);
if (dateOnly.getTime() === todayOnly.getTime()) {
return "Today";
} else if (dateOnly.getTime() === yesterdayOnly.getTime()) {
return "Yesterday";
} else {
// Format as "Jan 15" (short month, no year, respects locale)
return date.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
}
/**
* Check if two timestamps are on different days
*/
function isDifferentDay(timestamp1: number, timestamp2: number): boolean {
const date1 = new Date(timestamp1 * 1000);
const date2 = new Date(timestamp2 * 1000);
return (
date1.getFullYear() !== date2.getFullYear() ||
date1.getMonth() !== date2.getMonth() ||
date1.getDate() !== date2.getDate()
);
}
type ListItem<T> =
| { type: "item"; data: T }
| { type: "day-marker"; label: string; timestamp: number };
export function WalletHistoryList<T extends HistoryItem>({
items,
loading,
loadingMore = false,
hasMore = false,
loadFailed = false,
onLoadMore,
onRetry,
renderItem,
emptyMessage = "No transactions found",
}: WalletHistoryListProps<T>) {
// Process items to include day markers
const itemsWithMarkers = useMemo(() => {
if (!items || items.length === 0) return [];
const result: ListItem<T>[] = [];
items.forEach((item, index) => {
// Add day marker if this is the first item or if day changed
if (index === 0) {
result.push({
type: "day-marker",
label: formatDayMarker(item.timestamp),
timestamp: item.timestamp,
});
} else if (isDifferentDay(items[index - 1].timestamp, item.timestamp)) {
result.push({
type: "day-marker",
label: formatDayMarker(item.timestamp),
timestamp: item.timestamp,
});
}
result.push({ type: "item", data: item });
});
return result;
}, [items]);
// Loading state
if (loading) {
return (
<div className="flex h-full items-center justify-center">
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
</div>
);
}
// Error state
if (loadFailed) {
return (
<div className="flex h-full flex-col items-center justify-center gap-3 p-4">
<p className="text-sm text-muted-foreground text-center">
Failed to load transaction history
</p>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry}>
<RefreshCw className="mr-2 size-4" />
Retry
</Button>
)}
</div>
);
}
// Empty state
if (itemsWithMarkers.length === 0) {
return (
<div className="flex h-full items-center justify-center">
<p className="text-sm text-muted-foreground">{emptyMessage}</p>
</div>
);
}
return (
<Virtuoso
data={itemsWithMarkers}
endReached={hasMore ? onLoadMore : undefined}
itemContent={(index, item) => {
if (item.type === "day-marker") {
return (
<div
className="flex justify-center py-2"
key={`marker-${item.timestamp}`}
>
<Label className="text-[10px] text-muted-foreground">
{item.label}
</Label>
</div>
);
}
return renderItem(item.data, index);
}}
components={{
Footer: () =>
loadingMore ? (
<div className="flex justify-center py-4 border-b border-border">
<RefreshCw className="size-4 animate-spin text-muted-foreground" />
</div>
) : !hasMore && items.length > 0 ? (
<div className="py-4 text-center text-xs text-muted-foreground border-b border-border">
No more transactions
</div>
) : null,
}}
/>
);
}

View File

@@ -0,0 +1,117 @@
/**
* Wallet State Components
*
* Shared components for common wallet states (no wallet, locked, etc.)
*/
import { ReactNode } from "react";
import { Wallet, Lock, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
interface NoWalletViewProps {
/** Title to display */
title?: string;
/** Message to display */
message: string;
/** Action button */
action?: ReactNode;
}
export function NoWalletView({
title = "No Wallet Found",
message,
action,
}: NoWalletViewProps) {
return (
<div className="flex h-full items-center justify-center p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Wallet className="size-5" />
{title}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{message}</p>
{action}
</CardContent>
</Card>
</div>
);
}
interface WalletLockedViewProps {
/** Message to display */
message?: string;
/** Whether unlock is in progress */
loading: boolean;
/** Unlock button handler */
onUnlock: () => void;
}
export function WalletLockedView({
message = "Your wallet is locked. Unlock it to view your balance and history.",
loading,
onUnlock,
}: WalletLockedViewProps) {
return (
<div className="flex h-full items-center justify-center p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Lock className="size-5" />
Wallet Locked
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{message}</p>
<Button onClick={onUnlock} disabled={loading} className="w-full">
{loading ? (
<>
<RefreshCw className="mr-2 size-4 animate-spin" />
Unlocking...
</>
) : (
<>
<Lock className="mr-2 size-4" />
Unlock Wallet
</>
)}
</Button>
</CardContent>
</Card>
</div>
);
}
interface WalletErrorViewProps {
/** Error message */
message: string;
/** Retry handler */
onRetry?: () => void;
}
export function WalletErrorView({ message, onRetry }: WalletErrorViewProps) {
return (
<div className="flex h-full items-center justify-center p-8">
<Card className="max-w-md">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-destructive">
<Wallet className="size-5" />
Wallet Error
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<p className="text-sm text-muted-foreground">{message}</p>
{onRetry && (
<Button onClick={onRetry} variant="outline" className="w-full">
<RefreshCw className="mr-2 size-4" />
Retry
</Button>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,15 @@
/**
* Shared Wallet Components
*
* Reusable components for NWC and NIP-61 wallet viewers.
*/
export { WalletBalance } from "./WalletBalance";
export { WalletHeader, type WalletStatus } from "./WalletHeader";
export { WalletHistoryList, type HistoryItem } from "./WalletHistoryList";
export { TransactionRow } from "./TransactionRow";
export {
NoWalletView,
WalletLockedView,
WalletErrorView,
} from "./WalletStates";

154
src/hooks/useNip61Wallet.ts Normal file
View File

@@ -0,0 +1,154 @@
/**
* useNip61Wallet Hook
*
* Provides access to the user's NIP-60 Cashu wallet state.
* Uses applesauce-wallet casts for reactive wallet data.
*/
import { useMemo, useCallback, useState, useEffect, useRef } from "react";
import { use$ } from "applesauce-react/hooks";
import { castUser } from "applesauce-common/casts";
import type { Subscription } from "rxjs";
import { useAccount } from "@/hooks/useAccount";
import eventStore from "@/services/event-store";
import pool from "@/services/relay-pool";
import { hub } from "@/services/hub";
import {
couch,
UnlockWallet,
WALLET_KIND,
WALLET_TOKEN_KIND,
WALLET_HISTORY_KIND,
} from "@/services/nip61-wallet";
import { kinds, relaySet } from "applesauce-core/helpers";
// Import casts to enable user.wallet$ property
import "applesauce-wallet/casts";
/**
* Hook to access the user's NIP-60 Cashu wallet
*
* @returns Wallet state and actions
*/
export function useNip61Wallet() {
const { pubkey, canSign } = useAccount();
const [unlocking, setUnlocking] = useState(false);
const [error, setError] = useState<string | null>(null);
const subscriptionRef = useRef<Subscription | null>(null);
// Create User cast for the active pubkey
const user = useMemo(
() => (pubkey ? castUser(pubkey, eventStore) : undefined),
[pubkey],
);
// Get wallet observable from user cast
const wallet = use$(() => user?.wallet$, [user]);
// Get wallet state observables
const balance = use$(() => wallet?.balance$, [wallet]);
const tokens = use$(() => wallet?.tokens$, [wallet]);
const history = use$(() => wallet?.history$, [wallet]);
const mints = use$(() => wallet?.mints$, [wallet]);
const relays = use$(() => wallet?.relays$, [wallet]);
const received = use$(() => wallet?.received$, [wallet]);
// Get user's outbox relays for subscriptions
const outboxes = use$(() => user?.outboxes$, [user]);
// Subscribe to wallet events when pubkey is available
useEffect(() => {
if (!pubkey) return;
// Get all relevant relays
const walletRelays = relays || [];
const userOutboxes = outboxes || [];
const allRelays = relaySet(walletRelays, userOutboxes);
if (allRelays.length === 0) return;
// Subscribe to wallet-related events
const observable = pool.subscription(
allRelays,
[
// Wallet events
{
kinds: [WALLET_KIND, WALLET_TOKEN_KIND, WALLET_HISTORY_KIND],
authors: [pubkey],
},
// Token deletions
{
kinds: [kinds.EventDeletion],
"#k": [String(WALLET_TOKEN_KIND)],
authors: [pubkey],
},
],
{ eventStore },
);
const subscription = observable.subscribe();
subscriptionRef.current = subscription;
return () => {
subscription.unsubscribe();
subscriptionRef.current = null;
};
}, [pubkey, relays?.join(","), outboxes?.join(",")]);
// Unlock wallet action
const unlock = useCallback(async () => {
if (!canSign) {
setError("Cannot unlock: no signer available");
return false;
}
setUnlocking(true);
setError(null);
try {
await hub.run(UnlockWallet, { history: true, tokens: true });
return true;
} catch (err) {
console.error("Failed to unlock wallet:", err);
setError(err instanceof Error ? err.message : "Failed to unlock wallet");
return false;
} finally {
setUnlocking(false);
}
}, [canSign]);
// Calculate total balance across all mints
const totalBalance = useMemo(() => {
if (!balance) return 0;
return Object.values(balance).reduce((sum, amount) => sum + amount, 0);
}, [balance]);
return {
// Wallet state
wallet,
hasWallet: wallet !== undefined,
isUnlocked: wallet?.unlocked ?? false,
// Balances
balance, // { [mint: string]: number }
totalBalance, // Total across all mints
// Data
tokens,
history,
mints,
relays,
received, // Received nutzap IDs
// Actions
unlock,
unlocking,
// Error state
error,
// Utilities
canSign,
couch, // For advanced operations
};
}

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

@@ -0,0 +1,57 @@
/**
* Wallet Command Parser
*
* Parses arguments for the wallet command with subcommands:
* - nwc: NWC Lightning wallet (default)
* - nip-61: NIP-60 Cashu ecash wallet
*/
export type WalletSubcommand = "nwc" | "nip-61";
export interface WalletCommandResult {
subcommand: WalletSubcommand;
}
/**
* Parse wallet command arguments
*
* Usage:
* wallet - Open NWC Lightning wallet (default)
* wallet nwc - Open NWC Lightning wallet
* wallet nip-61 - Open NIP-60 Cashu ecash wallet
* wallet cashu - Alias for nip-61
* wallet ecash - Alias for nip-61
*/
export function parseWalletCommand(args: string[]): WalletCommandResult {
// Default to 'nwc' if no subcommand
if (args.length === 0) {
return { subcommand: "nwc" };
}
const subcommand = args[0].toLowerCase();
switch (subcommand) {
case "nwc":
case "lightning":
case "ln":
return { subcommand: "nwc" };
case "nip-61":
case "nip61":
case "cashu":
case "ecash":
case "nuts":
return { subcommand: "nip-61" };
default:
throw new Error(
`Unknown wallet type: ${subcommand}
Available wallet types:
nwc NWC Lightning wallet (default)
nip-61 NIP-60 Cashu ecash wallet
cashu Alias for nip-61
ecash Alias for nip-61`,
);
}
}

View File

@@ -0,0 +1,52 @@
/**
* NIP-61 Wallet Service
*
* Manages NIP-60 Cashu wallet operations using applesauce-wallet.
* Provides wallet unlocking, history, and token management.
*/
import { IndexedDBCouch } from "applesauce-wallet/helpers";
// Re-export wallet constants for use throughout the app
export {
WALLET_KIND,
WALLET_TOKEN_KIND,
WALLET_HISTORY_KIND,
NUTZAP_KIND,
} from "applesauce-wallet/helpers";
// Re-export wallet actions
export {
UnlockWallet,
ReceiveToken,
ReceiveNutzaps,
ConsolidateTokens,
RecoverFromCouch,
SetWalletMints,
SetWalletRelays,
} from "applesauce-wallet/actions";
// Re-export casts - IMPORTANT: this enables user.wallet$ property
export type {
Wallet,
WalletToken,
WalletHistory,
Nutzap,
} from "applesauce-wallet/casts";
// Import casts to enable User extension with wallet$ property
import "applesauce-wallet/casts";
/**
* Singleton IndexedDB couch for safe token operations
*
* The "couch" is temporary storage for proofs during operations that could fail.
* This prevents losing proofs if the app crashes mid-operation.
*/
export const couch = new IndexedDBCouch();
/**
* NIP-60 Nutzap Info event kind (kind:10019)
* Used to advertise mints, relays, and P2PK pubkey for receiving nutzaps
*/
export const NUTZAP_INFO_KIND = 10019;

View File

@@ -9,6 +9,7 @@ import { resolveNip05Batch, resolveDomainDirectoryBatch } from "@/lib/nip05";
import { parseChatCommand } from "@/lib/chat-parser";
import { parseBlossomCommand } from "@/lib/blossom-parser";
import { parseZapCommand } from "@/lib/zap-parser";
import { parseWalletCommand } from "@/lib/wallet-parser";
export interface ManPageEntry {
name: string;
@@ -843,14 +844,32 @@ export const manPages: Record<string, ManPageEntry> = {
wallet: {
name: "wallet",
section: "1",
synopsis: "wallet",
synopsis: "wallet [nwc|nip-61]",
description:
"View and manage your Nostr Wallet Connect (NWC) Lightning wallet. Display wallet balance, transaction history, send/receive payments, and view wallet capabilities. The wallet interface adapts based on the methods supported by your connected wallet provider.",
examples: ["wallet Open wallet viewer and manage Lightning payments"],
"View and manage your wallet. Supports NWC (Nostr Wallet Connect) for Lightning payments and NIP-61 for Cashu ecash. The wallet interface adapts based on the wallet type and supported capabilities.",
options: [
{
flag: "nwc",
description:
"NWC Lightning wallet (default). Connect via nostr+walletconnect:// URI.",
},
{
flag: "nip-61",
description:
"NIP-60 Cashu ecash wallet. View balance, history, and manage ecash tokens stored on Nostr relays.",
},
],
examples: [
"wallet Open NWC Lightning wallet (default)",
"wallet nwc Open NWC Lightning wallet",
"wallet nip-61 Open Cashu ecash wallet",
"wallet cashu Alias for nip-61",
],
seeAlso: ["profile"],
appId: "wallet",
category: "Nostr",
defaultProps: {},
argParser: (args: string[]) => parseWalletCommand(args),
defaultProps: { subcommand: "nwc" },
},
post: {
name: "post",