mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-11 16:07:15 +02:00
refactor: use applesauce-wallet-connect and move wallet to header
Replace custom NWC client implementation with applesauce-wallet-connect: - Install applesauce-wallet-connect for official NIP-47 support - Create nwc.ts service wrapper for WalletConnect singleton - Update NWCConnection type to match WalletConnectURI interface - Use service/relays/secret properties instead of custom names Move wallet display from user menu to header: - Create standalone WalletButton component - Add WalletButton to header next to UserMenu - Remove wallet UI from user menu dropdown - Show balance in header with yellow zap icon - Clicking wallet button opens connect dialog This provides better UX with wallet status visible in header and uses the official applesauce implementation for reliability.
This commit is contained in:
@@ -11,7 +11,7 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { parseNWCUri, NWCClient } from "@/services/nwc-client";
|
||||
import { createWalletFromURI } from "@/services/nwc";
|
||||
|
||||
interface ConnectWalletDialogProps {
|
||||
open: boolean;
|
||||
@@ -53,27 +53,31 @@ export default function ConnectWalletDialog({
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Parse the connection URI
|
||||
const connection = parseNWCUri(connectionString);
|
||||
|
||||
// Create NWC client
|
||||
const client = new NWCClient(connection);
|
||||
// Create wallet instance from connection string
|
||||
const wallet = createWalletFromURI(connectionString);
|
||||
|
||||
// Test the connection by getting wallet info
|
||||
const info = await client.getInfo();
|
||||
const info = await wallet.getInfo();
|
||||
|
||||
// Get initial balance
|
||||
let balance: number | undefined;
|
||||
try {
|
||||
balance = await client.getBalance();
|
||||
const balanceResult = await wallet.getBalance();
|
||||
balance = balanceResult.balance;
|
||||
} catch (err) {
|
||||
console.warn("[NWC] Failed to get balance:", err);
|
||||
// Balance is optional, continue anyway
|
||||
}
|
||||
|
||||
// Get connection details from the wallet instance
|
||||
const serialized = wallet.toJSON();
|
||||
|
||||
// Save connection to state
|
||||
setNWCConnection({
|
||||
...connection,
|
||||
service: serialized.service,
|
||||
relays: serialized.relays,
|
||||
secret: serialized.secret,
|
||||
lud16: serialized.lud16,
|
||||
balance,
|
||||
info: {
|
||||
alias: info.alias,
|
||||
|
||||
54
src/components/WalletButton.tsx
Normal file
54
src/components/WalletButton.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { useState } from "react";
|
||||
import { Wallet, Zap } from "lucide-react";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ConnectWalletDialog from "@/components/ConnectWalletDialog";
|
||||
|
||||
export default function WalletButton() {
|
||||
const { state } = useGrimoire();
|
||||
const nwcConnection = state.nwcConnection;
|
||||
const [showConnectWallet, setShowConnectWallet] = useState(false);
|
||||
|
||||
function formatBalance(millisats?: number): string {
|
||||
if (millisats === undefined) return "—";
|
||||
const sats = Math.floor(millisats / 1000);
|
||||
return sats.toLocaleString();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConnectWalletDialog
|
||||
open={showConnectWallet}
|
||||
onOpenChange={setShowConnectWallet}
|
||||
/>
|
||||
|
||||
{nwcConnection ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => setShowConnectWallet(true)}
|
||||
title={
|
||||
nwcConnection.info?.alias
|
||||
? `${nwcConnection.info.alias} - ${formatBalance(nwcConnection.balance)} sats`
|
||||
: `${formatBalance(nwcConnection.balance)} sats`
|
||||
}
|
||||
>
|
||||
<Zap className="size-4 text-yellow-500" />
|
||||
<span className="text-sm font-medium">
|
||||
{formatBalance(nwcConnection.balance)} sats
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowConnectWallet(true)}
|
||||
title="Connect Wallet"
|
||||
>
|
||||
<Wallet className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import CommandLauncher from "../CommandLauncher";
|
||||
import { GlobalAuthPrompt } from "../GlobalAuthPrompt";
|
||||
import { SpellbookDropdown } from "../SpellbookDropdown";
|
||||
import UserMenu from "../nostr/user-menu";
|
||||
import WalletButton from "../WalletButton";
|
||||
import { AppShellContext } from "./AppShellContext";
|
||||
|
||||
interface AppShellProps {
|
||||
@@ -76,7 +77,10 @@ export function AppShell({ children, hideBottomBar = false }: AppShellProps) {
|
||||
<SpellbookDropdown />
|
||||
</div>
|
||||
|
||||
<UserMenu />
|
||||
<div className="flex items-center gap-2">
|
||||
<WalletButton />
|
||||
<UserMenu />
|
||||
</div>
|
||||
</header>
|
||||
<section className="flex-1 relative overflow-hidden">
|
||||
{children}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { User, HardDrive, Palette, Wallet, Zap } from "lucide-react";
|
||||
import { User, HardDrive, Palette } from "lucide-react";
|
||||
import accounts from "@/services/accounts";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
@@ -22,7 +22,6 @@ import Nip05 from "./nip05";
|
||||
import { RelayLink } from "./RelayLink";
|
||||
import SettingsDialog from "@/components/SettingsDialog";
|
||||
import LoginDialog from "./LoginDialog";
|
||||
import ConnectWalletDialog from "@/components/ConnectWalletDialog";
|
||||
import { useState } from "react";
|
||||
import { useTheme } from "@/lib/themes";
|
||||
|
||||
@@ -57,13 +56,11 @@ function UserLabel({ pubkey }: { pubkey: string }) {
|
||||
|
||||
export default function UserMenu() {
|
||||
const account = use$(accounts.active$);
|
||||
const { state, addWindow, disconnectNWC } = useGrimoire();
|
||||
const { state, addWindow } = useGrimoire();
|
||||
const relays = state.activeAccount?.relays;
|
||||
const blossomServers = state.activeAccount?.blossomServers;
|
||||
const nwcConnection = state.nwcConnection;
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const [showConnectWallet, setShowConnectWallet] = useState(false);
|
||||
const { themeId, setTheme, availableThemes } = useTheme();
|
||||
|
||||
function openProfile() {
|
||||
@@ -80,48 +77,10 @@ export default function UserMenu() {
|
||||
accounts.removeAccount(account);
|
||||
}
|
||||
|
||||
function handleDisconnectWallet() {
|
||||
disconnectNWC();
|
||||
}
|
||||
|
||||
function formatBalance(millisats?: number): string {
|
||||
if (millisats === undefined) return "—";
|
||||
const sats = Math.floor(millisats / 1000);
|
||||
return sats.toLocaleString();
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog open={showSettings} onOpenChange={setShowSettings} />
|
||||
<LoginDialog open={showLogin} onOpenChange={setShowLogin} />
|
||||
<ConnectWalletDialog
|
||||
open={showConnectWallet}
|
||||
onOpenChange={setShowConnectWallet}
|
||||
/>
|
||||
|
||||
{/* Wallet Connection Button */}
|
||||
{nwcConnection ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="gap-2"
|
||||
onClick={() => setShowConnectWallet(true)}
|
||||
>
|
||||
<Zap className="size-4 text-yellow-500" />
|
||||
<span className="text-sm font-medium">
|
||||
{formatBalance(nwcConnection.balance)} sats
|
||||
</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setShowConnectWallet(true)}
|
||||
>
|
||||
<Wallet className="size-4 text-muted-foreground" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
@@ -198,40 +157,6 @@ export default function UserMenu() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{nwcConnection && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="text-xs text-muted-foreground font-normal flex items-center gap-1.5">
|
||||
<Wallet className="size-3.5" />
|
||||
<span>Wallet</span>
|
||||
</DropdownMenuLabel>
|
||||
<div className="px-2 py-1.5">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Balance:</span>
|
||||
<span className="font-medium">
|
||||
{formatBalance(nwcConnection.balance)} sats
|
||||
</span>
|
||||
</div>
|
||||
{nwcConnection.info?.alias && (
|
||||
<div className="flex items-center justify-between text-sm mt-1">
|
||||
<span className="text-muted-foreground">Wallet:</span>
|
||||
<span className="font-medium truncate max-w-[150px]">
|
||||
{nwcConnection.info.alias}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenuItem
|
||||
onClick={handleDisconnectWallet}
|
||||
className="cursor-crosshair text-destructive focus:text-destructive"
|
||||
>
|
||||
Disconnect Wallet
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={logout} className="cursor-crosshair">
|
||||
Log out
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Nostr Wallet Connect (NIP-47) Client
|
||||
*
|
||||
* Implements the client side of NIP-47 protocol for connecting to remote Lightning wallets.
|
||||
* Uses NIP-04 encryption for secure communication over Nostr relays.
|
||||
*
|
||||
* @see https://github.com/nostr-protocol/nips/blob/master/47.md
|
||||
*/
|
||||
|
||||
import { finalizeEvent, getPublicKey } from "nostr-tools";
|
||||
import { nip04 } from "nostr-tools";
|
||||
import pool from "./relay-pool";
|
||||
import type { NWCConnection } from "@/types/app";
|
||||
|
||||
/**
|
||||
* NIP-47 request/response types
|
||||
*/
|
||||
export interface NWCRequest {
|
||||
method: string;
|
||||
params?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface NWCResponse {
|
||||
result_type: string;
|
||||
result?: any;
|
||||
error?: {
|
||||
code: string;
|
||||
message: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a nostr+walletconnect:// URI into connection details
|
||||
* Format: nostr+walletconnect://[pubkey]?relay=[url]&secret=[hex]&lud16=[optional]
|
||||
*/
|
||||
export function parseNWCUri(uri: string): {
|
||||
walletPubkey: string;
|
||||
relays: string[];
|
||||
secret: string;
|
||||
} {
|
||||
// Remove protocol prefix
|
||||
const withoutProtocol = uri.replace(/^nostr\+walletconnect:\/\//i, "");
|
||||
|
||||
// Split pubkey from query params
|
||||
const [pubkey, queryString] = withoutProtocol.split("?");
|
||||
|
||||
if (!pubkey || !queryString) {
|
||||
throw new Error("Invalid NWC URI format");
|
||||
}
|
||||
|
||||
// Parse query parameters
|
||||
const params = new URLSearchParams(queryString);
|
||||
const relay = params.get("relay");
|
||||
const secret = params.get("secret");
|
||||
|
||||
if (!relay || !secret) {
|
||||
throw new Error("Missing required parameters: relay and secret");
|
||||
}
|
||||
|
||||
// Normalize relay URL
|
||||
const relayUrl = relay.startsWith("wss://")
|
||||
? relay
|
||||
: relay.startsWith("ws://")
|
||||
? relay
|
||||
: `wss://${relay}`;
|
||||
|
||||
return {
|
||||
walletPubkey: pubkey,
|
||||
relays: [relayUrl],
|
||||
secret,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* NWC Client Class
|
||||
* Manages encrypted communication with a NWC wallet service
|
||||
*/
|
||||
export class NWCClient {
|
||||
private walletPubkey: string;
|
||||
private relays: string[];
|
||||
private secret: Uint8Array;
|
||||
private clientSecretKey: Uint8Array;
|
||||
private clientPubkey: string;
|
||||
|
||||
constructor(connection: NWCConnection) {
|
||||
this.walletPubkey = connection.walletPubkey;
|
||||
this.relays = connection.relays;
|
||||
this.secret = this.hexToBytes(connection.secret);
|
||||
|
||||
// Derive client keypair from secret
|
||||
this.clientSecretKey = this.secret;
|
||||
this.clientPubkey = getPublicKey(this.clientSecretKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a command to the wallet and waits for response
|
||||
*/
|
||||
async sendRequest(
|
||||
method: string,
|
||||
params?: Record<string, any>,
|
||||
): Promise<NWCResponse> {
|
||||
const request: NWCRequest = {
|
||||
method,
|
||||
params,
|
||||
};
|
||||
|
||||
// Encrypt the request using NIP-04
|
||||
const encryptedContent = await nip04.encrypt(
|
||||
this.clientSecretKey,
|
||||
this.walletPubkey,
|
||||
JSON.stringify(request),
|
||||
);
|
||||
|
||||
// Create kind 23194 event (client request)
|
||||
const requestEvent = finalizeEvent(
|
||||
{
|
||||
kind: 23194,
|
||||
created_at: Math.floor(Date.now() / 1000),
|
||||
tags: [["p", this.walletPubkey]],
|
||||
content: encryptedContent,
|
||||
},
|
||||
this.clientSecretKey,
|
||||
);
|
||||
|
||||
// Publish request to relays
|
||||
await pool.publish(this.relays, requestEvent);
|
||||
|
||||
// Wait for response (kind 23195)
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
subscription.unsubscribe();
|
||||
reject(new Error("Request timeout"));
|
||||
}, 30000); // 30 second timeout
|
||||
|
||||
// Capture client keys for use in subscription
|
||||
const clientSecretKey = this.clientSecretKey;
|
||||
const walletPubkey = this.walletPubkey;
|
||||
|
||||
const observable = pool.subscription(this.relays, [
|
||||
{
|
||||
kinds: [23195],
|
||||
authors: [this.walletPubkey],
|
||||
"#p": [this.clientPubkey],
|
||||
since: requestEvent.created_at,
|
||||
},
|
||||
]);
|
||||
|
||||
const subscription = observable.subscribe({
|
||||
next: async (eventOrEose) => {
|
||||
// Skip EOSE markers
|
||||
if (typeof eventOrEose === "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Decrypt response
|
||||
const decryptedContent = await nip04.decrypt(
|
||||
clientSecretKey,
|
||||
walletPubkey,
|
||||
eventOrEose.content,
|
||||
);
|
||||
const response = JSON.parse(decryptedContent) as NWCResponse;
|
||||
|
||||
clearTimeout(timeout);
|
||||
subscription.unsubscribe();
|
||||
resolve(response);
|
||||
} catch (error) {
|
||||
console.error("[NWC] Failed to decrypt response:", error);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallet info (capabilities, alias, etc.)
|
||||
*/
|
||||
async getInfo(): Promise<{
|
||||
alias?: string;
|
||||
color?: string;
|
||||
pubkey?: string;
|
||||
network?: string;
|
||||
block_height?: number;
|
||||
block_hash?: string;
|
||||
methods: string[];
|
||||
notifications?: string[];
|
||||
}> {
|
||||
const response = await this.sendRequest("get_info");
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
return response.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current wallet balance in millisatoshis
|
||||
*/
|
||||
async getBalance(): Promise<number> {
|
||||
const response = await this.sendRequest("get_balance");
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
return response.result.balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay a BOLT11 invoice
|
||||
*/
|
||||
async payInvoice(invoice: string): Promise<{
|
||||
preimage: string;
|
||||
}> {
|
||||
const response = await this.sendRequest("pay_invoice", { invoice });
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
return response.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make a new invoice
|
||||
*/
|
||||
async makeInvoice(params: {
|
||||
amount: number;
|
||||
description?: string;
|
||||
description_hash?: string;
|
||||
expiry?: number;
|
||||
}): Promise<{
|
||||
type: string;
|
||||
invoice: string;
|
||||
description?: string;
|
||||
description_hash?: string;
|
||||
preimage?: string;
|
||||
payment_hash: string;
|
||||
amount: number;
|
||||
fees_paid: number;
|
||||
created_at: number;
|
||||
expires_at?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}> {
|
||||
const response = await this.sendRequest("make_invoice", params);
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error.message);
|
||||
}
|
||||
|
||||
return response.result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Convert hex string to Uint8Array
|
||||
*/
|
||||
private hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
43
src/services/nwc.ts
Normal file
43
src/services/nwc.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* NWC (Nostr Wallet Connect) Service
|
||||
*
|
||||
* Provides a singleton WalletConnect instance for the application using
|
||||
* applesauce-wallet-connect for NIP-47 Lightning wallet integration.
|
||||
*/
|
||||
|
||||
import { WalletConnect } from "applesauce-wallet-connect";
|
||||
import pool from "./relay-pool";
|
||||
|
||||
// Set the pool for wallet connect to use
|
||||
WalletConnect.pool = pool;
|
||||
|
||||
let walletInstance: WalletConnect | null = null;
|
||||
|
||||
/**
|
||||
* Creates a new WalletConnect instance from a connection string
|
||||
*/
|
||||
export function createWalletFromURI(connectionString: string): WalletConnect {
|
||||
walletInstance = WalletConnect.fromConnectURI(connectionString);
|
||||
return walletInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current wallet instance
|
||||
*/
|
||||
export function getWallet(): WalletConnect | null {
|
||||
return walletInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current wallet instance
|
||||
*/
|
||||
export function clearWallet(): void {
|
||||
walletInstance = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the wallet instance (used for restoring from saved state)
|
||||
*/
|
||||
export function setWallet(wallet: WalletConnect): void {
|
||||
walletInstance = wallet;
|
||||
}
|
||||
@@ -84,11 +84,13 @@ export interface RelayInfo {
|
||||
*/
|
||||
export interface NWCConnection {
|
||||
/** The wallet service's public key */
|
||||
walletPubkey: string;
|
||||
service: string;
|
||||
/** Relay URL(s) for communication */
|
||||
relays: string[];
|
||||
/** Shared secret for encryption (32-byte hex) */
|
||||
/** Shared secret for encryption */
|
||||
secret: string;
|
||||
/** Optional lightning address (lud16) */
|
||||
lud16?: string;
|
||||
/** Optional cached balance in millisats */
|
||||
balance?: number;
|
||||
/** Optional wallet info */
|
||||
|
||||
Reference in New Issue
Block a user