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:
Claude
2026-01-17 22:17:12 +00:00
parent 80461f7b31
commit 4a85979922
9 changed files with 139 additions and 358 deletions

17
package-lock.json generated
View File

@@ -42,6 +42,7 @@
"applesauce-relay": "^5.0.0",
"applesauce-signers": "^5.0.0",
"applesauce-wallet": "^5.0.0",
"applesauce-wallet-connect": "^5.0.1",
"blossom-client-sdk": "^4.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -5747,6 +5748,22 @@
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/applesauce-wallet-connect": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/applesauce-wallet-connect/-/applesauce-wallet-connect-5.0.1.tgz",
"integrity": "sha512-k/Gl2IIjfQelW4deN/0M9/I3uznUMZalGAP9/wPgwmAtUyaEHb8YJpOdxqLwCQ98vZMTAcwgK6hmXkAPqA6NTg==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "^1.7.1",
"applesauce-common": "^5.0.0",
"applesauce-core": "^5.0.0",
"rxjs": "^7.8.1"
},
"funding": {
"type": "lightning",
"url": "lightning:nostrudel@geyser.fund"
}
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",

View File

@@ -50,6 +50,7 @@
"applesauce-relay": "^5.0.0",
"applesauce-signers": "^5.0.0",
"applesauce-wallet": "^5.0.0",
"applesauce-wallet-connect": "^5.0.1",
"blossom-client-sdk": "^4.1.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -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,

View 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>
)}
</>
);
}

View File

@@ -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}

View File

@@ -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

View File

@@ -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
View 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;
}

View File

@@ -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 */