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.
This commit is contained in:
Claude
2026-01-15 12:40:29 +00:00
parent c832b58fd9
commit 31238412ac
2 changed files with 500 additions and 44 deletions

View File

@@ -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<WalletConfig | null>(null);
const [unspentTokens, setUnspentTokens] = useState<UnspentTokens[]>([]);
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [decryptionError, setDecryptionError] = useState<string | null>(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 (
<div className="p-6">
@@ -183,14 +285,17 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<Lock className="h-4 w-4" />
{isOwnWallet && walletConfig ? (
<Unlock className="h-4 w-4 text-green-500" />
) : (
<Lock className="h-4 w-4" />
)}
Wallet Status
</CardTitle>
{isOwnWallet && !unlocked && (
<Button size="sm" variant="outline" disabled>
<Download className="h-4 w-4 mr-2" />
Unlock Wallet
</Button>
{isOwnWallet && walletConfig && (
<div className="text-xs text-green-500 font-medium">
✓ Unlocked
</div>
)}
</div>
<CardDescription>
@@ -198,24 +303,37 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<Alert>
<Lock className="h-4 w-4" />
<AlertDescription>
{isOwnWallet ? (
<>
<strong>Wallet Encrypted:</strong> Your wallet data is
encrypted with NIP-44. Decryption functionality will be added
in a future update.
</>
) : (
<>
<strong>Privacy Protected:</strong> This wallet's data is
encrypted and cannot be viewed without the owner's private
key.
</>
)}
</AlertDescription>
</Alert>
{isOwnWallet && walletConfig && (
<Alert>
<Unlock className="h-4 w-4" />
<AlertDescription>
<strong>Wallet Unlocked:</strong> Your wallet has been decrypted
and is ready to use. Balance and transaction history are now
visible.
</AlertDescription>
</Alert>
)}
{isOwnWallet && !walletConfig && !isDecrypting && (
<Alert>
<Lock className="h-4 w-4" />
<AlertDescription>
<strong>Wallet Locked:</strong> Your wallet data is encrypted
with NIP-44.{" "}
{activeAccount?.nip44 ? "Decrypting..." : "Sign in to decrypt."}
</AlertDescription>
</Alert>
)}
{!isOwnWallet && (
<Alert>
<Lock className="h-4 w-4" />
<AlertDescription>
<strong>Privacy Protected:</strong> This wallet's data is
encrypted and cannot be viewed without the owner's private key.
</AlertDescription>
</Alert>
)}
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
@@ -250,11 +368,21 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
: "Unknown"}
</div>
</div>
{walletConfig && walletConfig.mints && (
<div className="col-span-2">
<div className="text-muted-foreground">Configured Mints</div>
<div className="font-mono text-xs space-y-1 mt-1">
{walletConfig.mints.map((mint) => (
<div key={mint}>✓ {getMintDisplayName(mint)}</div>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Balance Card (Encrypted) */}
{/* Balance Card */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -266,21 +394,78 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex items-center justify-center p-8 border-2 border-dashed rounded-lg">
<div className="text-center space-y-2">
<Lock className="h-8 w-8 mx-auto text-muted-foreground" />
<p className="text-sm text-muted-foreground">Balance encrypted</p>
<p className="text-xs text-muted-foreground">
{tokenEvents && tokenEvents.length > 0
? `${tokenEvents.length} token event(s) found`
: "No token events"}
</p>
{!isOwnWallet ? (
<div className="flex items-center justify-center p-8 border-2 border-dashed rounded-lg">
<div className="text-center space-y-2">
<Lock className="h-8 w-8 mx-auto text-muted-foreground" />
<p className="text-sm text-muted-foreground">
Balance encrypted
</p>
<p className="text-xs text-muted-foreground">
Only the owner can view this wallet
</p>
</div>
</div>
</div>
) : isDecrypting ? (
<div className="flex items-center justify-center p-8">
<div className="text-center space-y-2">
<Unlock className="h-8 w-8 mx-auto text-muted-foreground animate-pulse" />
<p className="text-sm text-muted-foreground">
Decrypting wallet...
</p>
</div>
</div>
) : decryptionError ? (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{decryptionError}</AlertDescription>
</Alert>
) : unspentTokens.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<Coins className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No funds available</p>
</div>
) : (
<div className="space-y-4">
{/* Total Balance */}
<div className="text-center p-6 bg-muted rounded-lg">
<div className="text-sm text-muted-foreground mb-2">
Total Balance
</div>
<div className="text-3xl font-bold">
{formatBalance(totalBalance)}
</div>
</div>
{/* Balance by Mint */}
{balanceByMint.size > 1 && (
<div className="space-y-2">
<div className="text-sm font-medium text-muted-foreground">
By Mint
</div>
{Array.from(balanceByMint.entries()).map(
([mint, balance]) => (
<div
key={mint}
className="flex justify-between items-center p-3 bg-muted rounded"
>
<div className="text-sm font-mono text-muted-foreground">
{getMintDisplayName(mint)}
</div>
<div className="text-sm font-semibold">
{formatBalance(balance)}
</div>
</div>
),
)}
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* Transaction History (Encrypted) */}
{/* Transaction History */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
@@ -290,7 +475,7 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
<CardDescription>Recent wallet activity</CardDescription>
</CardHeader>
<CardContent>
{historyEvents && historyEvents.length > 0 ? (
{!isOwnWallet && historyEvents && historyEvents.length > 0 ? (
<div className="flex items-center justify-center p-8 border-2 border-dashed rounded-lg">
<div className="text-center space-y-2">
<Lock className="h-8 w-8 mx-auto text-muted-foreground" />
@@ -298,15 +483,65 @@ export function WalletViewer({ pubkey }: WalletViewerProps) {
Transaction history encrypted
</p>
<p className="text-xs text-muted-foreground">
{historyEvents.length} transaction(s) found
Only the owner can view this wallet
</p>
</div>
</div>
) : (
) : transactions.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<History className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No transaction history</p>
</div>
) : (
<div className="space-y-2">
{transactions.slice(0, 10).map((tx, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-muted rounded hover:bg-muted/80 transition-colors"
>
<div className="flex items-center gap-3">
{tx.type === "mint" && (
<Zap className="h-4 w-4 text-green-500" />
)}
{tx.type === "melt" && (
<Zap className="h-4 w-4 text-orange-500" />
)}
{tx.type === "send" && (
<ArrowUpRight className="h-4 w-4 text-red-500" />
)}
{tx.type === "receive" && (
<ArrowDownLeft className="h-4 w-4 text-green-500" />
)}
<div>
<div className="text-sm font-medium capitalize">
{tx.type}
</div>
{tx.memo && (
<div className="text-xs text-muted-foreground">
{tx.memo}
</div>
)}
</div>
</div>
<div className="text-right">
<div
className={`text-sm font-semibold ${tx.type === "send" || tx.type === "melt" ? "text-red-500" : "text-green-500"}`}
>
{tx.type === "send" || tx.type === "melt" ? "-" : "+"}
{formatBalance(tx.amount)}
</div>
<div className="text-xs text-muted-foreground">
{new Date(tx.timestamp * 1000).toLocaleDateString()}
</div>
</div>
</div>
))}
{transactions.length > 10 && (
<div className="text-center text-sm text-muted-foreground pt-2">
Showing 10 of {transactions.length} transactions
</div>
)}
</div>
)}
</CardContent>
</Card>

221
src/lib/wallet-utils.ts Normal file
View File

@@ -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<WalletConfig | null> {
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<UnspentTokens | null> {
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<TransactionHistory | null> {
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<string, number> {
const balanceByMint = new Map<string, number>();
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<string, number>): 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;
}
}