mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 00:46:54 +02:00
Add NIP-60 Cashu wallet viewer command
Implemented a new `wallet` command to view and display NIP-60 Cashu wallets stored on Nostr relays. ## Changes ### New Command: wallet - Added `wallet [identifier]` command to man pages (src/types/man.ts) - Supports viewing own wallet (`wallet` or `wallet $me`) - Supports viewing other users' wallets (`wallet npub1...`, `wallet fiatjaf.com`) - Added wallet app ID to AppId type (src/types/app.ts) ### WalletViewer Component (src/components/WalletViewer.tsx) Created comprehensive NIP-60 wallet viewer with: **Data Fetching:** - Fetches kind:17375 (wallet config) using eventStore.replaceable() - Fetches kind:7375 (token events) using eventStore.timeline() - Fetches kind:7376 (history events) using eventStore.timeline() - Reactive updates using applesauce-react hooks (use$) **UI Features:** - Wallet status display (config, tokens, history counts) - Balance card (shows encrypted state, token count) - Transaction history card (shows encrypted state) - Developer info section with raw event JSON - Educational content about NIP-60 and Cashu - Links to compatible apps (noStrudel, Cashu.me) - "What is NIP-60?" help section **States Handled:** - No active account (requires login) - No wallet found (educational content for own wallet) - Wallet found but encrypted (displays structure) - Own wallet vs other user's wallet **Security:** - Shows wallet data is NIP-44 encrypted - Indicates decryption functionality is planned for future - Prevents viewing other users' encrypted wallet data - Educational content about privacy model ### WindowRenderer Integration - Added lazy import for WalletViewer component - Added case for "wallet" appId in switch statement - Passes pubkey prop to WalletViewer ## User Experience The wallet command provides: ✅ Clear indication of wallet existence ✅ Visual feedback on encrypted state ✅ Event counts for transparency ✅ Educational content for new users ✅ Developer-friendly raw event inspection ✅ Links to specification and compatible apps ## Future Enhancements This implementation provides the foundation for: - NIP-44 decryption with user's private key - Balance calculation from decrypted proofs - Transaction history display - Wallet management operations (pending applesauce-wallet API) ## Testing The component: - Handles missing account gracefully - Displays appropriate messaging for different states - Uses proper TypeScript types - Follows codebase patterns (ProfileViewer, BlossomViewer) - No TypeScript compilation errors ## Documentation Referenced - NIP-60 specification: github.com/nostr-protocol/nips/blob/master/60.md - .claude/skills/nostr/references/nip-60-cashu-wallet.md - applesauce documentation: hzrd149.github.io/applesauce/
This commit is contained in:
440
src/components/WalletViewer.tsx
Normal file
440
src/components/WalletViewer.tsx
Normal file
@@ -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 (
|
||||
<div className="p-6">
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
No active account. Please log in to view your wallet.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!walletExists) {
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Wallet className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">NIP-60 Cashu Wallet</h2>
|
||||
</div>
|
||||
|
||||
<Alert>
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertDescription>
|
||||
{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."}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{isOwnWallet && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>What is NIP-60?</CardTitle>
|
||||
<CardDescription>
|
||||
NIP-60 is a protocol for storing Cashu ecash wallets on Nostr
|
||||
relays
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2 text-sm text-muted-foreground">
|
||||
<p>
|
||||
<strong>Cashu</strong> is a privacy-preserving ecash system
|
||||
backed by Bitcoin via Lightning Network.
|
||||
</p>
|
||||
<p>
|
||||
<strong>NIP-60</strong> stores your wallet data encrypted on
|
||||
Nostr relays, making it accessible across applications.
|
||||
</p>
|
||||
<p className="pt-2">
|
||||
To create a wallet, use a NIP-60 compatible application like{" "}
|
||||
<a
|
||||
href="https://nostrudel.ninja"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
noStrudel
|
||||
</a>{" "}
|
||||
or{" "}
|
||||
<a
|
||||
href="https://cashu.me"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Cashu.me
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet className="h-5 w-5" />
|
||||
<h2 className="text-lg font-semibold">
|
||||
{isOwnWallet ? "Your" : "User"} Cashu Wallet
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{!isOwnWallet && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Viewing another user's wallet (encrypted)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Wallet Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription>
|
||||
NIP-60 wallet stored on Nostr relays
|
||||
</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>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<div className="text-muted-foreground">Config Event</div>
|
||||
<div className="font-mono text-xs">
|
||||
{walletConfigEvent ? "✓ Found (kind:17375)" : "✗ Not found"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Token Events</div>
|
||||
<div className="font-mono text-xs">
|
||||
{tokenEvents && tokenEvents.length > 0
|
||||
? `✓ ${tokenEvents.length} event(s) (kind:7375)`
|
||||
: "✗ No tokens"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">History Events</div>
|
||||
<div className="font-mono text-xs">
|
||||
{historyEvents && historyEvents.length > 0
|
||||
? `✓ ${historyEvents.length} event(s) (kind:7376)`
|
||||
: "○ No history"}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-muted-foreground">Created</div>
|
||||
<div className="font-mono text-xs">
|
||||
{walletConfigEvent
|
||||
? new Date(
|
||||
walletConfigEvent.created_at * 1000,
|
||||
).toLocaleDateString()
|
||||
: "Unknown"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Balance Card (Encrypted) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Coins className="h-4 w-4" />
|
||||
Balance
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Total unspent tokens across all mints
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Transaction History (Encrypted) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<History className="h-4 w-4" />
|
||||
Transaction History
|
||||
</CardTitle>
|
||||
<CardDescription>Recent wallet activity</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{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" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Transaction history encrypted
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{historyEvents.length} transaction(s) found
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Developer Info */}
|
||||
{isOwnWallet && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">
|
||||
Raw Events (For Developers)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Wallet Config Event (kind:17375)
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-muted p-3 rounded overflow-x-auto">
|
||||
{walletConfigEvent
|
||||
? JSON.stringify(walletConfigEvent, null, 2)
|
||||
: "Not found"}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
{tokenEvents && tokenEvents.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
Token Events (kind:7375) - {tokenEvents.length} event(s)
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-muted p-3 rounded overflow-x-auto">
|
||||
{JSON.stringify(tokenEvents, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{historyEvents && historyEvents.length > 0 && (
|
||||
<details>
|
||||
<summary className="cursor-pointer text-sm font-medium">
|
||||
History Events (kind:7376) - {historyEvents.length} event(s)
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs bg-muted p-3 rounded overflow-x-auto">
|
||||
{JSON.stringify(historyEvents, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Help Section */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">About NIP-60 Cashu Wallets</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 text-sm text-muted-foreground">
|
||||
<div>
|
||||
<strong className="text-foreground">What you're seeing:</strong>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>
|
||||
kind:17375 - Encrypted wallet configuration (mint URLs, wallet
|
||||
private key)
|
||||
</li>
|
||||
<li>kind:7375 - Encrypted unspent Cashu proofs (ecash tokens)</li>
|
||||
<li>kind:7376 - Encrypted transaction history (optional)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong className="text-foreground">Privacy & Security:</strong>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>All wallet data is encrypted with NIP-44</li>
|
||||
<li>Only the owner can decrypt and spend the funds</li>
|
||||
<li>Wallet follows you across Nostr applications</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<strong className="text-foreground">Compatible Apps:</strong>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>
|
||||
<a
|
||||
href="https://nostrudel.ninja"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
noStrudel
|
||||
</a>{" "}
|
||||
- Full-featured Nostr client with NIP-60 wallet
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://cashu.me"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Cashu.me
|
||||
</a>{" "}
|
||||
- Cashu ecash wallet
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs">
|
||||
📚 Learn more:{" "}
|
||||
<a
|
||||
href="https://github.com/nostr-protocol/nips/blob/master/60.md"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
NIP-60 Specification
|
||||
</a>
|
||||
{" | "}
|
||||
<a
|
||||
href="https://cashu.space"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Cashu Protocol
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 = <WalletViewer pubkey={window.props.pubkey} />;
|
||||
break;
|
||||
default:
|
||||
content = (
|
||||
<div className="p-4 text-muted-foreground">
|
||||
|
||||
Reference in New Issue
Block a user