diff --git a/src/components/Nip61WalletViewer.tsx b/src/components/Nip61WalletViewer.tsx
index 2e17a31..0b71257 100644
--- a/src/components/Nip61WalletViewer.tsx
+++ b/src/components/Nip61WalletViewer.tsx
@@ -1,14 +1,28 @@
/**
* Nip61WalletViewer Component
*
- * Displays NIP-60 Cashu wallet information:
- * - Wallet balance (total and per-mint)
- * - Transaction history
- * - Placeholder send/receive buttons
+ * Displays NIP-60 Cashu wallet with proper state machine:
+ * - discovering: Searching for wallet on network
+ * - missing: No wallet found
+ * - locked: Wallet found but encrypted
+ * - unlocked: Wallet decrypted, showing balance and history
*/
import { useState, useMemo, useCallback } from "react";
-import { Send, Download, RefreshCw, Settings, Coins } from "lucide-react";
+import {
+ Send,
+ Download,
+ RefreshCw,
+ Settings,
+ Coins,
+ Loader2,
+ Wallet,
+ Lock,
+ ChevronRight,
+ ChevronDown,
+ ArrowDownLeft,
+ Search,
+} from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import {
@@ -16,18 +30,27 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogFooter,
+} from "@/components/ui/dialog";
+import { Label } from "@/components/ui/label";
+import { Switch } from "@/components/ui/switch";
import {
WalletBalance,
WalletHeader,
WalletHistoryList,
TransactionRow,
- NoWalletView,
- WalletLockedView,
type HistoryItem,
} from "@/components/wallet";
+import { CodeCopyButton } from "@/components/CodeCopyButton";
import { useNip61Wallet } from "@/hooks/useNip61Wallet";
import { useGrimoire } from "@/core/state";
import { useAccount } from "@/hooks/useAccount";
+import { formatTimestamp } from "@/hooks/useLocale";
import type { WalletHistory } from "applesauce-wallet/casts";
/**
@@ -61,7 +84,7 @@ function MintBalanceBreakdown({
{new URL(mint).hostname}
- {blurred ? "✦✦✦" : amount.toLocaleString()} sats
+ {blurred ? "✦✦✦" : amount.toLocaleString()}
))}
@@ -71,6 +94,99 @@ function MintBalanceBreakdown({
);
}
+/**
+ * Discovering state view - searching for wallet
+ */
+function DiscoveringView() {
+ return (
+
+
+
+
+
Searching for Wallet
+
+ Looking for your NIP-60 Cashu wallet on the Nostr network...
+
+
+
+ Querying relays
+
+
+ );
+}
+
+/**
+ * Missing wallet state view
+ */
+function MissingWalletView() {
+ return (
+
+
+
+
+
No Cashu Wallet Found
+
+ No NIP-60 Cashu wallet was found for your account. Wallet creation is
+ not yet supported in Grimoire.
+
+
+ );
+}
+
+/**
+ * Locked wallet state view
+ */
+function LockedWalletView({
+ onUnlock,
+ unlocking,
+}: {
+ onUnlock: () => void;
+ unlocking: boolean;
+}) {
+ return (
+
+
+
+
+
Wallet Locked
+
+ Your Cashu wallet is encrypted. Unlock it to view your balance and
+ transaction history.
+
+
+
+ );
+}
+
+/**
+ * Not logged in view
+ */
+function NotLoggedInView() {
+ return (
+
+
+
+
+
Not Logged In
+
+ Log in with a Nostr account to access your Cashu wallet.
+
+
+ );
+}
+
/**
* Transform WalletHistory into HistoryItem for the list
*/
@@ -86,8 +202,9 @@ export default function Nip61WalletViewer() {
const { state, toggleWalletBalancesBlur } = useGrimoire();
const { isLoggedIn } = useAccount();
const {
- hasWallet,
- isUnlocked,
+ isDiscovering,
+ isMissing,
+ isLocked,
balance,
totalBalance,
history,
@@ -95,9 +212,19 @@ export default function Nip61WalletViewer() {
unlock,
unlocking,
error,
+ syncEnabled,
+ toggleSyncEnabled,
} = useNip61Wallet();
const [refreshing, setRefreshing] = useState(false);
+ const [selectedTransaction, setSelectedTransaction] =
+ useState(null);
+ const [detailDialogOpen, setDetailDialogOpen] = useState(false);
+ const [showRawTransaction, setShowRawTransaction] = useState(false);
+ const [copiedRawTx, setCopiedRawTx] = useState(false);
+ const [settingsOpen, setSettingsOpen] = useState(false);
+
+ const blurred = state.walletBalancesBlurred ?? false;
// Transform history for the list component
const historyItems = useMemo(() => {
@@ -109,7 +236,6 @@ export default function Nip61WalletViewer() {
const handleRefresh = useCallback(async () => {
setRefreshing(true);
try {
- // Re-unlock to refresh encrypted content
await unlock();
toast.success("Wallet refreshed");
} catch (err) {
@@ -120,6 +246,12 @@ export default function Nip61WalletViewer() {
}
}, [unlock]);
+ // Open transaction detail
+ const handleTransactionClick = useCallback((entry: WalletHistory) => {
+ setSelectedTransaction(entry);
+ setDetailDialogOpen(true);
+ }, []);
+
// Render history entry
const renderHistoryEntry = useCallback(
(item: HistoryItem) => {
@@ -136,60 +268,62 @@ export default function Nip61WalletViewer() {
label={
Locked
}
+ onClick={() => handleTransactionClick(entry)}
/>
);
}
- // 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 (
- {entry.event.created_at
- ? new Date(entry.event.created_at * 1000).toLocaleTimeString()
- : "Transaction"}
+ {formatTimestamp(entry.event.created_at, "datetime")}
}
+ onClick={() => handleTransactionClick(entry)}
/>
);
},
- [state.walletBalancesBlurred],
+ [blurred, handleTransactionClick],
);
// Not logged in
if (!isLoggedIn) {
return (
-
+
+
+
);
}
- // No wallet found
- if (!hasWallet) {
+ // Discovering
+ if (isDiscovering) {
return (
-
+
+
+
);
}
- // Wallet locked
- if (!isUnlocked) {
+ // Missing wallet
+ if (isMissing) {
return (
-
+
+
+
+ );
+ }
+
+ // Locked wallet
+ if (isLocked) {
+ return (
+
+
+
);
}
@@ -230,14 +364,14 @@ export default function Nip61WalletViewer() {
- Settings (coming soon)
+ Settings
}
@@ -253,16 +387,12 @@ export default function Nip61WalletViewer() {
{/* Balance */}
{/* Balance by mint */}
-
+
{/* Send / Receive Buttons (Placeholders) */}
@@ -302,6 +432,170 @@ export default function Nip61WalletViewer() {
/>
+
+ {/* Transaction Detail Dialog */}
+
+
+ {/* Settings Dialog */}
+
);
}
diff --git a/src/core/logic.ts b/src/core/logic.ts
index 07527c1..756b204 100644
--- a/src/core/logic.ts
+++ b/src/core/logic.ts
@@ -602,3 +602,13 @@ export const toggleWalletBalancesBlur = (
walletBalancesBlurred: !state.walletBalancesBlurred,
};
};
+
+export const setCashuWalletSyncEnabled = (
+ state: GrimoireState,
+ enabled: boolean,
+): GrimoireState => {
+ return {
+ ...state,
+ cashuWalletSyncEnabled: enabled,
+ };
+};
diff --git a/src/core/state.ts b/src/core/state.ts
index 98f8618..7fe5efa 100644
--- a/src/core/state.ts
+++ b/src/core/state.ts
@@ -374,6 +374,13 @@ export const useGrimoire = () => {
setState((prev) => Logic.toggleWalletBalancesBlur(prev));
}, [setState]);
+ const setCashuWalletSyncEnabled = useCallback(
+ (enabled: boolean) => {
+ setState((prev) => Logic.setCashuWalletSyncEnabled(prev, enabled));
+ },
+ [setState],
+ );
+
return {
state,
isTemporary,
@@ -405,5 +412,6 @@ export const useGrimoire = () => {
updateNWCInfo,
disconnectNWC,
toggleWalletBalancesBlur,
+ setCashuWalletSyncEnabled,
};
};
diff --git a/src/hooks/useNip61Wallet.ts b/src/hooks/useNip61Wallet.ts
index 479bc86..a1af725 100644
--- a/src/hooks/useNip61Wallet.ts
+++ b/src/hooks/useNip61Wallet.ts
@@ -1,8 +1,11 @@
/**
* useNip61Wallet Hook
*
- * Provides access to the user's NIP-60 Cashu wallet state.
- * Uses applesauce-wallet casts for reactive wallet data.
+ * Provides access to the user's NIP-60 Cashu wallet with proper state machine:
+ * - discovering: Querying the network to find if wallet exists
+ * - missing: No wallet found after search completed
+ * - locked: Wallet exists but encrypted
+ * - unlocked: Wallet decrypted and ready to use
*/
import { useMemo, useCallback, useState, useEffect, useRef } from "react";
@@ -21,10 +24,19 @@ import {
WALLET_HISTORY_KIND,
} from "@/services/nip61-wallet";
import { kinds, relaySet } from "applesauce-core/helpers";
+import { useGrimoire } from "@/core/state";
// Import casts to enable user.wallet$ property
import "applesauce-wallet/casts";
+/** Wallet state machine states */
+export type WalletState =
+ | "discovering"
+ | "missing"
+ | "locked"
+ | "unlocked"
+ | "error";
+
/**
* Hook to access the user's NIP-60 Cashu wallet
*
@@ -32,8 +44,10 @@ import "applesauce-wallet/casts";
*/
export function useNip61Wallet() {
const { pubkey, canSign } = useAccount();
+ const { state: appState, setCashuWalletSyncEnabled } = useGrimoire();
const [unlocking, setUnlocking] = useState(false);
const [error, setError] = useState(null);
+ const [discoveryComplete, setDiscoveryComplete] = useState(false);
const subscriptionRef = useRef(null);
// Create User cast for the active pubkey
@@ -56,20 +70,46 @@ export function useNip61Wallet() {
// Get user's outbox relays for subscriptions
const outboxes = use$(() => user?.outboxes$, [user]);
+ // Determine current wallet state
+ const walletState: WalletState = useMemo(() => {
+ if (!pubkey) return "missing";
+ if (error) return "error";
+ if (!discoveryComplete) return "discovering";
+ if (!wallet) return "missing";
+ if (wallet.unlocked) return "unlocked";
+ return "locked";
+ }, [pubkey, error, discoveryComplete, wallet]);
+
// Subscribe to wallet events when pubkey is available
useEffect(() => {
- if (!pubkey) return;
+ if (!pubkey) {
+ setDiscoveryComplete(true);
+ return;
+ }
+
+ // Reset discovery state when pubkey changes
+ setDiscoveryComplete(false);
+ setError(null);
// Get all relevant relays
const walletRelays = relays || [];
const userOutboxes = outboxes || [];
const allRelays = relaySet(walletRelays, userOutboxes);
- if (allRelays.length === 0) return;
+ // If no relays, use some defaults for discovery
+ const queryRelays =
+ allRelays.length > 0
+ ? allRelays
+ : [
+ "wss://relay.damus.io",
+ "wss://relay.primal.net",
+ "wss://nos.lol",
+ "wss://relay.nostr.band",
+ ];
// Subscribe to wallet-related events
const observable = pool.subscription(
- allRelays,
+ queryRelays,
[
// Wallet events
{
@@ -86,10 +126,29 @@ export function useNip61Wallet() {
{ eventStore },
);
- const subscription = observable.subscribe();
+ const subscription = observable.subscribe({
+ next: (response) => {
+ // When we receive EOSE, discovery is complete
+ if (typeof response === "string" && response === "EOSE") {
+ setDiscoveryComplete(true);
+ }
+ },
+ error: (err) => {
+ console.error("Wallet subscription error:", err);
+ setError(err instanceof Error ? err.message : "Subscription failed");
+ setDiscoveryComplete(true);
+ },
+ });
+
subscriptionRef.current = subscription;
+ // Set a timeout for discovery in case no EOSE is received
+ const timeout = setTimeout(() => {
+ setDiscoveryComplete(true);
+ }, 10000); // 10 second timeout
+
return () => {
+ clearTimeout(timeout);
subscription.unsubscribe();
subscriptionRef.current = null;
};
@@ -123,14 +182,32 @@ export function useNip61Wallet() {
return Object.values(balance).reduce((sum, amount) => sum + amount, 0);
}, [balance]);
+ // Sort balances by amount (descending)
+ const sortedBalance = useMemo(() => {
+ if (!balance) return undefined;
+ const entries = Object.entries(balance);
+ entries.sort((a, b) => b[1] - a[1]);
+ return Object.fromEntries(entries);
+ }, [balance]);
+
+ // Toggle sync setting
+ const toggleSyncEnabled = useCallback(() => {
+ setCashuWalletSyncEnabled(!appState.cashuWalletSyncEnabled);
+ }, [appState.cashuWalletSyncEnabled, setCashuWalletSyncEnabled]);
+
return {
- // Wallet state
+ // State machine
+ walletState,
+ isDiscovering: walletState === "discovering",
+ isMissing: walletState === "missing",
+ isLocked: walletState === "locked",
+ isUnlocked: walletState === "unlocked",
+
+ // Wallet instance
wallet,
- hasWallet: wallet !== undefined,
- isUnlocked: wallet?.unlocked ?? false,
// Balances
- balance, // { [mint: string]: number }
+ balance: sortedBalance, // { [mint: string]: number } sorted by amount
totalBalance, // Total across all mints
// Data
@@ -144,6 +221,10 @@ export function useNip61Wallet() {
unlock,
unlocking,
+ // Sync setting
+ syncEnabled: appState.cashuWalletSyncEnabled ?? false,
+ toggleSyncEnabled,
+
// Error state
error,
diff --git a/src/types/app.ts b/src/types/app.ts
index 5b114e5..f00f2a4 100644
--- a/src/types/app.ts
+++ b/src/types/app.ts
@@ -140,4 +140,5 @@ export interface GrimoireState {
};
nwcConnection?: NWCConnection;
walletBalancesBlurred?: boolean; // Privacy: blur balances and transaction amounts
+ cashuWalletSyncEnabled?: boolean; // Keep NIP-60 Cashu wallet unlocked and synced
}