diff --git a/src/components/WalletViewer.tsx b/src/components/WalletViewer.tsx new file mode 100644 index 0000000..b17ecf3 --- /dev/null +++ b/src/components/WalletViewer.tsx @@ -0,0 +1,440 @@ +import { useEventStore, use$ } from "applesauce-react/hooks"; +import { useGrimoire } from "@/core/state"; +import { + Wallet, + Lock, + Download, + AlertCircle, + Coins, + History, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { useMemo, useState } from "react"; +import type { NostrEvent } from "nostr-tools"; + +export interface WalletViewerProps { + pubkey: string; +} + +/** + * WalletViewer - NIP-60 Cashu Wallet Display + * Shows wallet configuration, balance, and transaction history + * + * NIP-60 Event Kinds: + * - kind:17375 - Wallet config (replaceable, encrypted) + * - kind:7375 - Unspent tokens (multiple allowed, encrypted) + * - kind:7376 - Transaction history (optional, encrypted) + */ +export function WalletViewer({ pubkey }: WalletViewerProps) { + const { state } = useGrimoire(); + const eventStore = useEventStore(); + const [unlocked, setUnlocked] = useState(false); + + // Resolve $me alias + const resolvedPubkey = + pubkey === "$me" ? state.activeAccount?.pubkey : pubkey; + + // Fetch wallet config event (kind:17375) + const walletConfigEvent = use$( + () => + resolvedPubkey + ? eventStore.replaceable(17375, resolvedPubkey) + : undefined, + [resolvedPubkey, eventStore], + ); + + // Fetch token events (kind:7375) + const tokenEvents = use$( + () => + resolvedPubkey + ? eventStore.timeline([ + { + kinds: [7375], + authors: [resolvedPubkey], + }, + ]) + : undefined, + [resolvedPubkey, eventStore], + ); + + // Fetch history events (kind:7376) + const historyEvents = use$( + () => + resolvedPubkey + ? eventStore.timeline([ + { + kinds: [7376], + authors: [resolvedPubkey], + }, + ]) + : undefined, + [resolvedPubkey, eventStore], + ); + + const isOwnWallet = resolvedPubkey === state.activeAccount?.pubkey; + + // Check if wallet exists + const walletExists = walletConfigEvent !== undefined; + + if (!resolvedPubkey) { + return ( +
+ + + + No active account. Please log in to view your wallet. + + +
+ ); + } + + if (!walletExists) { + return ( +
+
+ +

NIP-60 Cashu Wallet

+
+ + + + + {isOwnWallet + ? "No NIP-60 wallet found for your account. You can create a wallet using a compatible Cashu wallet application." + : "No NIP-60 wallet found for this user."} + + + + {isOwnWallet && ( + + + What is NIP-60? + + NIP-60 is a protocol for storing Cashu ecash wallets on Nostr + relays + + + +

+ Cashu is a privacy-preserving ecash system + backed by Bitcoin via Lightning Network. +

+

+ NIP-60 stores your wallet data encrypted on + Nostr relays, making it accessible across applications. +

+

+ To create a wallet, use a NIP-60 compatible application like{" "} + + noStrudel + {" "} + or{" "} + + Cashu.me + + . +

+
+
+ )} +
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

+ {isOwnWallet ? "Your" : "User"} Cashu Wallet +

+
+ + {!isOwnWallet && ( +
+ Viewing another user's wallet (encrypted) +
+ )} +
+ + {/* Wallet Status */} + + +
+ + + Wallet Status + + {isOwnWallet && !unlocked && ( + + )} +
+ + NIP-60 wallet stored on Nostr relays + +
+ + + + + {isOwnWallet ? ( + <> + Wallet Encrypted: Your wallet data is + encrypted with NIP-44. Decryption functionality will be added + in a future update. + + ) : ( + <> + Privacy Protected: This wallet's data is + encrypted and cannot be viewed without the owner's private + key. + + )} + + + +
+
+
Config Event
+
+ {walletConfigEvent ? "✓ Found (kind:17375)" : "✗ Not found"} +
+
+
+
Token Events
+
+ {tokenEvents && tokenEvents.length > 0 + ? `✓ ${tokenEvents.length} event(s) (kind:7375)` + : "✗ No tokens"} +
+
+
+
History Events
+
+ {historyEvents && historyEvents.length > 0 + ? `✓ ${historyEvents.length} event(s) (kind:7376)` + : "○ No history"} +
+
+
+
Created
+
+ {walletConfigEvent + ? new Date( + walletConfigEvent.created_at * 1000, + ).toLocaleDateString() + : "Unknown"} +
+
+
+
+
+ + {/* Balance Card (Encrypted) */} + + + + + Balance + + + Total unspent tokens across all mints + + + +
+
+ +

Balance encrypted

+

+ {tokenEvents && tokenEvents.length > 0 + ? `${tokenEvents.length} token event(s) found` + : "No token events"} +

+
+
+
+
+ + {/* Transaction History (Encrypted) */} + + + + + Transaction History + + Recent wallet activity + + + {historyEvents && historyEvents.length > 0 ? ( +
+
+ +

+ Transaction history encrypted +

+

+ {historyEvents.length} transaction(s) found +

+
+
+ ) : ( +
+ +

No transaction history

+
+ )} +
+
+ + {/* Developer Info */} + {isOwnWallet && ( + + + + Raw Events (For Developers) + + + +
+ + Wallet Config Event (kind:17375) + +
+                {walletConfigEvent
+                  ? JSON.stringify(walletConfigEvent, null, 2)
+                  : "Not found"}
+              
+
+ + {tokenEvents && tokenEvents.length > 0 && ( +
+ + Token Events (kind:7375) - {tokenEvents.length} event(s) + +
+                  {JSON.stringify(tokenEvents, null, 2)}
+                
+
+ )} + + {historyEvents && historyEvents.length > 0 && ( +
+ + History Events (kind:7376) - {historyEvents.length} event(s) + +
+                  {JSON.stringify(historyEvents, null, 2)}
+                
+
+ )} +
+
+ )} + + {/* Help Section */} + + + About NIP-60 Cashu Wallets + + +
+ What you're seeing: +
    +
  • + kind:17375 - Encrypted wallet configuration (mint URLs, wallet + private key) +
  • +
  • kind:7375 - Encrypted unspent Cashu proofs (ecash tokens)
  • +
  • kind:7376 - Encrypted transaction history (optional)
  • +
+
+ +
+ Privacy & Security: +
    +
  • All wallet data is encrypted with NIP-44
  • +
  • Only the owner can decrypt and spend the funds
  • +
  • Wallet follows you across Nostr applications
  • +
+
+ +
+ Compatible Apps: + +
+ +
+

+ 📚 Learn more:{" "} + + NIP-60 Specification + + {" | "} + + Cashu Protocol + +

+
+
+
+
+ ); +} diff --git a/src/components/WindowRenderer.tsx b/src/components/WindowRenderer.tsx index 84d0ffa..ed0475e 100644 --- a/src/components/WindowRenderer.tsx +++ b/src/components/WindowRenderer.tsx @@ -42,6 +42,9 @@ const SpellbooksViewer = lazy(() => const BlossomViewer = lazy(() => import("./BlossomViewer").then((m) => ({ default: m.BlossomViewer })), ); +const WalletViewer = lazy(() => + import("./WalletViewer").then((m) => ({ default: m.WalletViewer })), +); // Loading fallback component function ViewerLoading() { @@ -210,6 +213,9 @@ export function WindowRenderer({ window, onClose }: WindowRendererProps) { /> ); break; + case "wallet": + content = ; + break; default: content = (
diff --git a/src/types/app.ts b/src/types/app.ts index ac99533..6f8984c 100644 --- a/src/types/app.ts +++ b/src/types/app.ts @@ -20,6 +20,7 @@ export type AppId = | "spells" | "spellbooks" | "blossom" + | "wallet" | "win"; export interface WindowInstance { diff --git a/src/types/man.ts b/src/types/man.ts index 69f47d2..af7dfb2 100644 --- a/src/types/man.ts +++ b/src/types/man.ts @@ -575,4 +575,45 @@ export const manPages: Record = { }, defaultProps: { subcommand: "servers" }, }, + wallet: { + name: "wallet", + section: "1", + synopsis: "wallet [identifier]", + description: + "View and manage your NIP-60 Cashu wallet. Displays wallet balance, transaction history, and token details. The wallet is stored encrypted on Nostr relays (kind 17375 for config, kind 7375 for tokens, kind 7376 for history). Use $me or omit identifier to view your own wallet.", + options: [ + { + flag: "[identifier]", + description: + "Wallet owner identifier (npub, hex pubkey, NIP-05, or $me). Defaults to your active account", + }, + ], + examples: [ + "wallet View your wallet", + "wallet $me View your wallet (explicit)", + "wallet fiatjaf.com View another user's wallet (if public)", + "wallet npub1... View wallet by npub", + ], + seeAlso: ["profile", "req"], + appId: "wallet", + category: "Nostr", + argParser: async (args: string[]) => { + // If no args, use active account + if (args.length === 0) { + return { pubkey: "$me" }; + } + + // Parse identifier similar to profile command + const identifier = args[0]; + + // Handle $me alias + if (identifier === "$me") { + return { pubkey: "$me" }; + } + + // For now, simple pass-through - could add NIP-05 resolution here + return { pubkey: identifier }; + }, + defaultProps: { pubkey: "$me" }, + }, };