From 31238412acaa707c0b537fdc10b1a88a22767e71 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Jan 2026 12:40:29 +0000 Subject: [PATCH] Implement NIP-60 wallet decryption and balance display Add comprehensive NIP-60 Cashu wallet functionality: - Create wallet-utils.ts with NIP-60 data structures and helpers: * TypeScript interfaces for WalletConfig, UnspentTokens, Transaction * Decryption functions for wallet config, tokens, and history * Balance calculation and formatting utilities * Transaction sorting and mint display helpers - Enhance WalletViewer component with full decryption support: * Integrate with applesauce-accounts for NIP-44 decryption * Auto-decrypt wallet events when active account is available * Display actual balance with breakdown by mint * Show transaction history with type icons and timestamps * Visual status indicators (locked/unlocked states) * Loading states during decryption * Error handling for decryption failures - Features: * Privacy-first: only decrypt for own wallet * Reactive updates when wallet events change * Support for multiple mints per wallet * Transaction type indicators (mint, melt, send, receive) * Human-readable timestamps and amounts This enables users to view their NIP-60 Cashu wallet balance and transaction history directly in Grimoire, with full NIP-44 encryption support through applesauce-accounts. --- src/components/WalletViewer.tsx | 323 +++++++++++++++++++++++++++----- src/lib/wallet-utils.ts | 221 ++++++++++++++++++++++ 2 files changed, 500 insertions(+), 44 deletions(-) create mode 100644 src/lib/wallet-utils.ts diff --git a/src/components/WalletViewer.tsx b/src/components/WalletViewer.tsx index b17ecf3..297532b 100644 --- a/src/components/WalletViewer.tsx +++ b/src/components/WalletViewer.tsx @@ -3,12 +3,14 @@ import { useGrimoire } from "@/core/state"; import { Wallet, Lock, - Download, + Unlock, AlertCircle, Coins, History, + ArrowUpRight, + ArrowDownLeft, + Zap, } from "lucide-react"; -import { Button } from "@/components/ui/button"; import { Card, CardContent, @@ -17,8 +19,21 @@ import { CardTitle, } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { useMemo, useState } from "react"; -import type { NostrEvent } from "nostr-tools"; +import { useMemo, useState, useEffect } from "react"; +import accountManager from "@/services/accounts"; +import { + decryptWalletConfig, + decryptUnspentTokens, + decryptTransactionHistory, + calculateBalance, + getTotalBalance, + formatBalance, + sortTransactions, + getMintDisplayName, + type WalletConfig, + type UnspentTokens, + type Transaction, +} from "@/lib/wallet-utils"; export interface WalletViewerProps { pubkey: string; @@ -36,7 +51,13 @@ export interface WalletViewerProps { export function WalletViewer({ pubkey }: WalletViewerProps) { const { state } = useGrimoire(); const eventStore = useEventStore(); - const [unlocked, setUnlocked] = useState(false); + + // State for decrypted wallet data + const [walletConfig, setWalletConfig] = useState(null); + const [unspentTokens, setUnspentTokens] = useState([]); + const [transactions, setTransactions] = useState([]); + const [decryptionError, setDecryptionError] = useState(null); + const [isDecrypting, setIsDecrypting] = useState(false); // Resolve $me alias const resolvedPubkey = @@ -84,6 +105,87 @@ export function WalletViewer({ pubkey }: WalletViewerProps) { // Check if wallet exists const walletExists = walletConfigEvent !== undefined; + // Get active account from accountManager + const activeAccount = use$(accountManager.active$); + + // Decrypt wallet data when events are available (only for own wallet) + useEffect(() => { + if (!isOwnWallet || !activeAccount?.nip44) return; + if (!walletConfigEvent) return; + + const decryptWalletData = async () => { + setIsDecrypting(true); + setDecryptionError(null); + + try { + // Decrypt wallet config + if (walletConfigEvent) { + const config = await decryptWalletConfig( + walletConfigEvent, + activeAccount, + ); + if (config) { + setWalletConfig(config); + } + } + + // Decrypt token events + if (tokenEvents && tokenEvents.length > 0) { + const decryptedTokens: UnspentTokens[] = []; + for (const event of tokenEvents) { + const tokens = await decryptUnspentTokens(event, activeAccount); + if (tokens) { + decryptedTokens.push(tokens); + } + } + setUnspentTokens(decryptedTokens); + } + + // Decrypt history events + if (historyEvents && historyEvents.length > 0) { + const allTransactions: Transaction[] = []; + for (const event of historyEvents) { + const history = await decryptTransactionHistory( + event, + activeAccount, + ); + if (history && history.transactions) { + allTransactions.push(...history.transactions); + } + } + setTransactions(sortTransactions(allTransactions)); + } + } catch (error) { + console.error("Decryption error:", error); + setDecryptionError( + error instanceof Error + ? error.message + : "Failed to decrypt wallet data", + ); + } finally { + setIsDecrypting(false); + } + }; + + decryptWalletData(); + }, [ + isOwnWallet, + activeAccount, + walletConfigEvent, + tokenEvents, + historyEvents, + ]); + + // Calculate balance + const balanceByMint = useMemo(() => { + if (unspentTokens.length === 0) return new Map(); + return calculateBalance(unspentTokens); + }, [unspentTokens]); + + const totalBalance = useMemo(() => { + return getTotalBalance(balanceByMint); + }, [balanceByMint]); + if (!resolvedPubkey) { return (
@@ -183,14 +285,17 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
- + {isOwnWallet && walletConfig ? ( + + ) : ( + + )} Wallet Status - {isOwnWallet && !unlocked && ( - + {isOwnWallet && walletConfig && ( +
+ ✓ Unlocked +
)}
@@ -198,24 +303,37 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
- - - - {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. - - )} - - + {isOwnWallet && walletConfig && ( + + + + Wallet Unlocked: Your wallet has been decrypted + and is ready to use. Balance and transaction history are now + visible. + + + )} + + {isOwnWallet && !walletConfig && !isDecrypting && ( + + + + Wallet Locked: Your wallet data is encrypted + with NIP-44.{" "} + {activeAccount?.nip44 ? "Decrypting..." : "Sign in to decrypt."} + + + )} + + {!isOwnWallet && ( + + + + Privacy Protected: This wallet's data is + encrypted and cannot be viewed without the owner's private key. + + + )}
@@ -250,11 +368,21 @@ export function WalletViewer({ pubkey }: WalletViewerProps) { : "Unknown"}
+ {walletConfig && walletConfig.mints && ( +
+
Configured Mints
+
+ {walletConfig.mints.map((mint) => ( +
✓ {getMintDisplayName(mint)}
+ ))} +
+
+ )}
- {/* Balance Card (Encrypted) */} + {/* Balance Card */} @@ -266,21 +394,78 @@ export function WalletViewer({ pubkey }: WalletViewerProps) { -
-
- -

Balance encrypted

-

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

+ {!isOwnWallet ? ( +
+
+ +

+ Balance encrypted +

+

+ Only the owner can view this wallet +

+
-
+ ) : isDecrypting ? ( +
+
+ +

+ Decrypting wallet... +

+
+
+ ) : decryptionError ? ( + + + {decryptionError} + + ) : unspentTokens.length === 0 ? ( +
+ +

No funds available

+
+ ) : ( +
+ {/* Total Balance */} +
+
+ Total Balance +
+
+ {formatBalance(totalBalance)} +
+
+ + {/* Balance by Mint */} + {balanceByMint.size > 1 && ( +
+
+ By Mint +
+ {Array.from(balanceByMint.entries()).map( + ([mint, balance]) => ( +
+
+ {getMintDisplayName(mint)} +
+
+ {formatBalance(balance)} +
+
+ ), + )} +
+ )} +
+ )} - {/* Transaction History (Encrypted) */} + {/* Transaction History */} @@ -290,7 +475,7 @@ export function WalletViewer({ pubkey }: WalletViewerProps) { Recent wallet activity - {historyEvents && historyEvents.length > 0 ? ( + {!isOwnWallet && historyEvents && historyEvents.length > 0 ? (
@@ -298,15 +483,65 @@ export function WalletViewer({ pubkey }: WalletViewerProps) { Transaction history encrypted

- {historyEvents.length} transaction(s) found + Only the owner can view this wallet

- ) : ( + ) : transactions.length === 0 ? (

No transaction history

+ ) : ( +
+ {transactions.slice(0, 10).map((tx, index) => ( +
+
+ {tx.type === "mint" && ( + + )} + {tx.type === "melt" && ( + + )} + {tx.type === "send" && ( + + )} + {tx.type === "receive" && ( + + )} +
+
+ {tx.type} +
+ {tx.memo && ( +
+ {tx.memo} +
+ )} +
+
+
+
+ {tx.type === "send" || tx.type === "melt" ? "-" : "+"} + {formatBalance(tx.amount)} +
+
+ {new Date(tx.timestamp * 1000).toLocaleDateString()} +
+
+
+ ))} + {transactions.length > 10 && ( +
+ Showing 10 of {transactions.length} transactions +
+ )} +
)}
diff --git a/src/lib/wallet-utils.ts b/src/lib/wallet-utils.ts new file mode 100644 index 0000000..abf269a --- /dev/null +++ b/src/lib/wallet-utils.ts @@ -0,0 +1,221 @@ +/** + * NIP-60 Cashu Wallet Utilities + * Helpers for working with encrypted wallet data on Nostr + */ + +import type { NostrEvent } from "nostr-tools"; +import type { ISigner } from "applesauce-signers"; + +/** + * NIP-60 Wallet Configuration (kind:17375) + * Stored as encrypted JSON in event content + */ +export interface WalletConfig { + /** Wallet private key (for signing Cashu operations) */ + privkey: string; + /** Array of mint URLs this wallet uses */ + mints: string[]; + /** Optional wallet metadata */ + name?: string; + description?: string; + unit?: string; // e.g., "sat", "usd" + relays?: string[]; +} + +/** + * NIP-60 Unspent Token Data (kind:7375) + * Contains Cashu proofs that can be spent + */ +export interface UnspentTokens { + /** Mint URL these proofs are from */ + mint: string; + /** Array of Cashu proofs (ecash tokens) */ + proofs: CashuProof[]; +} + +/** + * Cashu Proof (ecash token) + * Represents a spendable token with a specific amount + */ +export interface CashuProof { + /** Unique identifier for this proof */ + id: string; + /** Amount in base units (sats) */ + amount: number; + /** Secret blinding factor */ + secret: string; + /** Proof signature from mint */ + C: string; + /** Keyset ID from mint */ + keyset_id?: string; +} + +/** + * NIP-60 Transaction History (kind:7376) + * Records of past wallet operations + */ +export interface TransactionHistory { + /** Array of transaction records */ + transactions: Transaction[]; +} + +/** + * Transaction Record + */ +export interface Transaction { + /** Transaction type */ + type: "mint" | "melt" | "send" | "receive"; + /** Amount in base units */ + amount: number; + /** Mint URL */ + mint: string; + /** Unix timestamp */ + timestamp: number; + /** Optional memo/note */ + memo?: string; + /** Optional related event ID (for sends/receives) */ + event_id?: string; +} + +/** + * Decrypts a NIP-60 wallet config event + * @param event kind:17375 wallet config event + * @param signer Account signer with nip44 support + * @returns Decrypted wallet configuration + */ +export async function decryptWalletConfig( + event: NostrEvent, + signer: ISigner, +): Promise { + if (!signer.nip44) { + throw new Error("Signer does not support NIP-44 encryption"); + } + + try { + const decrypted = await signer.nip44.decrypt(event.pubkey, event.content); + return JSON.parse(decrypted) as WalletConfig; + } catch (error) { + console.error("Failed to decrypt wallet config:", error); + return null; + } +} + +/** + * Decrypts a NIP-60 unspent tokens event + * @param event kind:7375 unspent tokens event + * @param signer Account signer with nip44 support + * @returns Decrypted unspent tokens + */ +export async function decryptUnspentTokens( + event: NostrEvent, + signer: ISigner, +): Promise { + if (!signer.nip44) { + throw new Error("Signer does not support NIP-44 encryption"); + } + + try { + const decrypted = await signer.nip44.decrypt(event.pubkey, event.content); + return JSON.parse(decrypted) as UnspentTokens; + } catch (error) { + console.error("Failed to decrypt unspent tokens:", error); + return null; + } +} + +/** + * Decrypts a NIP-60 transaction history event + * @param event kind:7376 transaction history event + * @param signer Account signer with nip44 support + * @returns Decrypted transaction history + */ +export async function decryptTransactionHistory( + event: NostrEvent, + signer: ISigner, +): Promise { + if (!signer.nip44) { + throw new Error("Signer does not support NIP-44 encryption"); + } + + try { + const decrypted = await signer.nip44.decrypt(event.pubkey, event.content); + return JSON.parse(decrypted) as TransactionHistory; + } catch (error) { + console.error("Failed to decrypt transaction history:", error); + return null; + } +} + +/** + * Calculates total balance from all unspent token events + * @param tokenEvents Array of decrypted UnspentTokens + * @returns Total balance in base units (sats) grouped by mint + */ +export function calculateBalance( + tokenEvents: UnspentTokens[], +): Map { + const balanceByMint = new Map(); + + for (const tokens of tokenEvents) { + const currentBalance = balanceByMint.get(tokens.mint) || 0; + const tokenSum = tokens.proofs.reduce( + (sum, proof) => sum + proof.amount, + 0, + ); + balanceByMint.set(tokens.mint, currentBalance + tokenSum); + } + + return balanceByMint; +} + +/** + * Gets total balance across all mints + * @param balanceByMint Map of mint URLs to balances + * @returns Total balance in base units (sats) + */ +export function getTotalBalance(balanceByMint: Map): number { + let total = 0; + for (const balance of balanceByMint.values()) { + total += balance; + } + return total; +} + +/** + * Formats balance for display + * @param sats Balance in satoshis + * @param unit Unit to display ("sat" or "btc") + * @returns Formatted balance string + */ +export function formatBalance( + sats: number, + unit: "sat" | "btc" = "sat", +): string { + if (unit === "btc") { + return (sats / 100_000_000).toFixed(8) + " BTC"; + } + return sats.toLocaleString() + " sats"; +} + +/** + * Sorts transactions by timestamp (most recent first) + * @param transactions Array of transactions + * @returns Sorted array + */ +export function sortTransactions(transactions: Transaction[]): Transaction[] { + return [...transactions].sort((a, b) => b.timestamp - a.timestamp); +} + +/** + * Gets a short mint display name from URL + * @param mintUrl Full mint URL + * @returns Shortened display name + */ +export function getMintDisplayName(mintUrl: string): string { + try { + const url = new URL(mintUrl); + return url.hostname; + } catch { + return mintUrl; + } +}