mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-13 17:07:27 +02:00
feat: make NWC connections more robust and wallet state reactive (#227)
* fix(nwc): improve connection reliability and add health tracking - Add connection status observable (disconnected/connecting/connected/error) - Validate wallet connection on restore using support$ observable with 10s timeout - Add notification subscription error recovery with exponential backoff (5 retries) - Add retry logic for balance refresh (3 retries with backoff) - Use library's support$ observable for wallet capabilities (cached by applesauce) - Replace manual getInfo() calls with reactive support$ subscription - Add visual connection status indicator in WalletViewer header - Add reconnect button when connection is in error state - Store network info in cached connection for display https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ * feat(wallet): add copy NWC connection string button to header Adds a copy button (Copy/Check icons) in the wallet header before the refresh button that copies the NWC connection string to clipboard for easy sharing or backup. https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ * fix(wallet): use CopyCheck icon and fix transaction loading - Change Check to CopyCheck icon for copy NWC string button - Add walletMethods computed value that combines support$ observable with cached info fallback from initial connection - Fix transaction history not loading because support$ waits for kind 13194 events which many NWC wallets don't publish - The cached info from getInfo() RPC call is now used as fallback https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ * refactor(nwc): simplify with derived state from observables Production-ready refactor of NWC implementation: nwc.ts: - Add wallet$ observable for reactive wallet instance access - Remove redundant subscribeToSupport() - only needed for validation - Cleaner code organization with clear sections useWallet.ts: - All state derived from observables (no useState for wallet) - Move walletMethods computation to hook (reusable) - isConnected derived from connectionStatus - Simplified from 240 to 170 lines WalletViewer.tsx: - Use walletMethods from hook instead of local useMemo - Simpler connection state tracking via connectionStatus - Remove redundant wallet variable from destructuring - No color change on copy NWC string (per feedback) https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ * refactor(wallet): use useCopy hook for clipboard operations Replace manual clipboard state management with useCopy hook: - copyInvoice/invoiceCopied for generated invoice - copyRawTx/rawTxCopied for transaction JSON - copyNwc/nwcCopied for NWC connection string Benefits: - Cleaner code (removed manual setTimeout calls) - Automatic timeout cleanup on unmount - Consistent copy behavior across all clipboard operations https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ * feat(wallet): implement lazy-loaded transactions observable - Add shared wallet types (Transaction, TransactionsState) in src/types/wallet.ts - Add transactionsState$ observable to NWC service for shared tx state - Implement loadTransactions, loadMoreTransactions, and retryLoadTransactions - Auto-refresh transactions on payment notifications - Simplify WalletViewer to use observable state instead of local state - Remove manual transaction loading logic from component https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ * fix(wallet): update balance observable on initial connect - Call refreshBalance() in createWalletFromURI to fetch initial balance - Update balance$ directly when ConnectWalletDialog gets balance - Fixes issue where WalletViewer showed "-" after connecting while user menu showed correct balance (different data sources) https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ * refactor(wallet): use single data source for balance across UI Remove fallback to nwcConnection.balance in user-menu - now both WalletViewer and user-menu use balance$ observable as the single source of truth for wallet balance. https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ * fix(wallet): address code review issues and simplify user menu - Fix memory leak: track retry timeout and clear on disconnect - Add explicit WalletSupport type for support observable - Add comments explaining balance refresh error handling behavior - Add comment about restoreWallet not being awaited intentionally - User menu now uses connectionStatus observable (shows connecting/error states) - Remove wallet name display from user menu (simplifies UI) - Remove unused walletServiceProfile hook and getWalletName function https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ * refactor(wallet): extract WalletConnectionStatus component - Create reusable WalletConnectionStatus component for connection indicator - Remove rounded borders from indicator (now square) - Export getConnectionStatusColor helper for custom usage - Use component in both user-menu and WalletViewer - Supports size (sm/md), showLabel, and className props https://claude.ai/code/session_01CnJgjFMvZHZWs2ujAiWAiQ --------- Co-authored-by: Claude <noreply@anthropic.com>
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 { createWalletFromURI } from "@/services/nwc";
|
||||
import { createWalletFromURI, balance$ } from "@/services/nwc";
|
||||
|
||||
interface ConnectWalletDialogProps {
|
||||
open: boolean;
|
||||
@@ -66,6 +66,8 @@ export default function ConnectWalletDialog({
|
||||
try {
|
||||
const balanceResult = await wallet.getBalance();
|
||||
balance = balanceResult.balance;
|
||||
// Update the observable immediately so WalletViewer shows correct balance
|
||||
balance$.next(balance);
|
||||
} catch (err) {
|
||||
console.warn("[NWC] Failed to get balance:", err);
|
||||
// Balance is optional, continue anyway
|
||||
@@ -83,6 +85,7 @@ export default function ConnectWalletDialog({
|
||||
balance,
|
||||
info: {
|
||||
alias: info.alias,
|
||||
network: info.network,
|
||||
methods: info.methods,
|
||||
notifications: info.notifications,
|
||||
},
|
||||
@@ -96,6 +99,7 @@ export default function ConnectWalletDialog({
|
||||
// Update info
|
||||
updateNWCInfo({
|
||||
alias: info.alias,
|
||||
network: info.network,
|
||||
methods: info.methods,
|
||||
notifications: info.notifications,
|
||||
});
|
||||
|
||||
65
src/components/WalletConnectionStatus.tsx
Normal file
65
src/components/WalletConnectionStatus.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* WalletConnectionStatus Component
|
||||
*
|
||||
* Displays a visual indicator for wallet connection status.
|
||||
* Shows different colors and animations based on status:
|
||||
* - connected: green
|
||||
* - connecting: yellow (pulsing)
|
||||
* - error: red
|
||||
* - disconnected: gray
|
||||
*/
|
||||
|
||||
import type { NWCConnectionStatus } from "@/services/nwc";
|
||||
|
||||
interface WalletConnectionStatusProps {
|
||||
status: NWCConnectionStatus;
|
||||
/** Size of the indicator */
|
||||
size?: "sm" | "md";
|
||||
/** Whether to show the status label */
|
||||
showLabel?: boolean;
|
||||
/** Additional class names */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: "size-1.5",
|
||||
md: "size-2",
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the color class for a connection status
|
||||
*/
|
||||
export function getConnectionStatusColor(status: NWCConnectionStatus): string {
|
||||
switch (status) {
|
||||
case "connected":
|
||||
return "bg-green-500";
|
||||
case "connecting":
|
||||
return "bg-yellow-500 animate-pulse";
|
||||
case "error":
|
||||
return "bg-red-500";
|
||||
default:
|
||||
return "bg-gray-500";
|
||||
}
|
||||
}
|
||||
|
||||
export function WalletConnectionStatus({
|
||||
status,
|
||||
size = "sm",
|
||||
showLabel = false,
|
||||
className = "",
|
||||
}: WalletConnectionStatusProps) {
|
||||
const colorClass = getConnectionStatusColor(status);
|
||||
|
||||
if (!showLabel) {
|
||||
return (
|
||||
<span className={`${sizeClasses[size]} ${colorClass} ${className}`} />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1.5 ${className}`}>
|
||||
<span className={`${sizeClasses[size]} ${colorClass}`} />
|
||||
<span className="text-sm font-medium capitalize">{status}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* Layout: Header → Big centered balance → Send/Receive buttons → Transaction list
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
||||
import { useState, useEffect, useMemo, useRef } from "react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Wallet,
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Download,
|
||||
Info,
|
||||
Copy,
|
||||
CopyCheck,
|
||||
Check,
|
||||
ArrowUpRight,
|
||||
ArrowDownLeft,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Virtuoso } from "react-virtuoso";
|
||||
import { useWallet } from "@/hooks/useWallet";
|
||||
import { useCopy } from "@/hooks/useCopy";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import { decode as decodeBolt11 } from "light-bolt11-decoder";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -60,32 +62,8 @@ import { KindRenderer } from "./nostr/kinds";
|
||||
import { RichText } from "./nostr/RichText";
|
||||
import { UserName } from "./nostr/UserName";
|
||||
import { CodeCopyButton } from "./CodeCopyButton";
|
||||
|
||||
interface Transaction {
|
||||
type: "incoming" | "outgoing";
|
||||
invoice?: string;
|
||||
description?: string;
|
||||
description_hash?: string;
|
||||
preimage?: string;
|
||||
payment_hash?: string;
|
||||
amount: number;
|
||||
fees_paid?: number;
|
||||
created_at: number;
|
||||
expires_at?: number;
|
||||
settled_at?: number;
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface WalletInfo {
|
||||
alias?: string;
|
||||
color?: string;
|
||||
pubkey?: string;
|
||||
network?: string;
|
||||
block_height?: number;
|
||||
block_hash?: string;
|
||||
methods: string[];
|
||||
notifications?: string[];
|
||||
}
|
||||
import { WalletConnectionStatus } from "./WalletConnectionStatus";
|
||||
import type { Transaction } from "@/types/wallet";
|
||||
|
||||
interface InvoiceDetails {
|
||||
amount?: number;
|
||||
@@ -94,7 +72,6 @@ interface InvoiceDetails {
|
||||
expiry?: number;
|
||||
}
|
||||
|
||||
const BATCH_SIZE = 20;
|
||||
const PAYMENT_CHECK_INTERVAL = 5000; // Check every 5 seconds
|
||||
|
||||
/**
|
||||
@@ -406,33 +383,30 @@ export default function WalletViewer() {
|
||||
toggleWalletBalancesBlur,
|
||||
} = useGrimoire();
|
||||
const {
|
||||
wallet,
|
||||
balance,
|
||||
isConnected,
|
||||
getInfo,
|
||||
connectionStatus,
|
||||
lastError,
|
||||
support,
|
||||
walletMethods, // Combined support$ + cached info fallback
|
||||
transactionsState,
|
||||
refreshBalance,
|
||||
listTransactions,
|
||||
makeInvoice,
|
||||
payInvoice,
|
||||
lookupInvoice,
|
||||
disconnect,
|
||||
reconnect,
|
||||
loadTransactions,
|
||||
loadMoreTransactions,
|
||||
retryLoadTransactions,
|
||||
} = useWallet();
|
||||
|
||||
const [walletInfo, setWalletInfo] = useState<WalletInfo | null>(null);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [refreshingBalance, setRefreshingBalance] = useState(false);
|
||||
const [connectDialogOpen, setConnectDialogOpen] = useState(false);
|
||||
const [disconnectDialogOpen, setDisconnectDialogOpen] = useState(false);
|
||||
const [txLoadAttempted, setTxLoadAttempted] = useState(false);
|
||||
const [txLoadFailed, setTxLoadFailed] = useState(false);
|
||||
|
||||
// Use refs to track loading attempts without causing re-renders
|
||||
const walletInfoLoadedRef = useRef(false);
|
||||
const lastConnectionStateRef = useRef(isConnected);
|
||||
// Rate limiting ref
|
||||
const lastBalanceRefreshRef = useRef(0);
|
||||
const lastTxLoadRef = useRef(0);
|
||||
|
||||
// Send dialog state
|
||||
const [sendDialogOpen, setSendDialogOpen] = useState(false);
|
||||
@@ -452,7 +426,6 @@ export default function WalletViewer() {
|
||||
const [generatedPaymentHash, setGeneratedPaymentHash] = useState("");
|
||||
const [invoiceQR, setInvoiceQR] = useState("");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [checkingPayment, setCheckingPayment] = useState(false);
|
||||
|
||||
// Transaction detail dialog state
|
||||
@@ -460,107 +433,37 @@ export default function WalletViewer() {
|
||||
useState<Transaction | null>(null);
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [showRawTransaction, setShowRawTransaction] = useState(false);
|
||||
const [copiedRawTx, setCopiedRawTx] = useState(false);
|
||||
|
||||
// Load wallet info when connected
|
||||
useEffect(() => {
|
||||
// Detect connection state changes
|
||||
if (isConnected !== lastConnectionStateRef.current) {
|
||||
lastConnectionStateRef.current = isConnected;
|
||||
walletInfoLoadedRef.current = false;
|
||||
// Copy hooks for clipboard operations
|
||||
const { copy: copyInvoice, copied: invoiceCopied } = useCopy(2000);
|
||||
const { copy: copyRawTx, copied: rawTxCopied } = useCopy(2000);
|
||||
const { copy: copyNwc, copied: nwcCopied } = useCopy(2000);
|
||||
|
||||
if (isConnected) {
|
||||
// Reset transaction loading flags when wallet connects
|
||||
setTxLoadAttempted(false);
|
||||
setTxLoadFailed(false);
|
||||
setTransactions([]);
|
||||
setWalletInfo(null);
|
||||
} else {
|
||||
// Clear all state when wallet disconnects
|
||||
setTxLoadAttempted(false);
|
||||
setTxLoadFailed(false);
|
||||
setTransactions([]);
|
||||
setWalletInfo(null);
|
||||
setLoading(false);
|
||||
setLoadingMore(false);
|
||||
setHasMore(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Load wallet info if connected and not yet loaded
|
||||
if (isConnected && !walletInfoLoadedRef.current) {
|
||||
walletInfoLoadedRef.current = true;
|
||||
getInfo()
|
||||
.then((info) => setWalletInfo(info))
|
||||
.catch((error) => {
|
||||
console.error("Failed to load wallet info:", error);
|
||||
toast.error("Failed to load wallet info");
|
||||
walletInfoLoadedRef.current = false; // Allow retry
|
||||
});
|
||||
}
|
||||
}, [isConnected, getInfo]);
|
||||
|
||||
// Load transactions when wallet info is available (only once)
|
||||
// Trigger lazy load of transactions when wallet supports it
|
||||
useEffect(() => {
|
||||
if (
|
||||
walletInfo?.methods.includes("list_transactions") &&
|
||||
!txLoadAttempted &&
|
||||
!loading
|
||||
walletMethods.includes("list_transactions") &&
|
||||
!transactionsState.initialized
|
||||
) {
|
||||
setLoading(true);
|
||||
setTxLoadAttempted(true);
|
||||
listTransactions({
|
||||
limit: BATCH_SIZE,
|
||||
offset: 0,
|
||||
})
|
||||
.then((result) => {
|
||||
const txs = result.transactions || [];
|
||||
setTransactions(txs);
|
||||
setHasMore(txs.length === BATCH_SIZE);
|
||||
setTxLoadFailed(false);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load transactions:", error);
|
||||
setTxLoadFailed(true);
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
});
|
||||
loadTransactions();
|
||||
}
|
||||
}, [walletInfo, txLoadAttempted, loading, listTransactions]);
|
||||
|
||||
// Helper to reload transactions (resets flags to trigger reload)
|
||||
const reloadTransactions = useCallback(() => {
|
||||
// Rate limiting: minimum 5 seconds between transaction reloads
|
||||
const now = Date.now();
|
||||
const timeSinceLastLoad = now - lastTxLoadRef.current;
|
||||
if (timeSinceLastLoad < 5000) {
|
||||
const waitTime = Math.ceil((5000 - timeSinceLastLoad) / 1000);
|
||||
toast.warning(`Please wait ${waitTime}s before reloading transactions`);
|
||||
return;
|
||||
}
|
||||
|
||||
lastTxLoadRef.current = now;
|
||||
setTxLoadAttempted(false);
|
||||
setTxLoadFailed(false);
|
||||
}, []);
|
||||
}, [walletMethods, transactionsState.initialized, loadTransactions]);
|
||||
|
||||
// Poll for payment status when waiting for invoice to be paid
|
||||
useEffect(() => {
|
||||
if (!generatedPaymentHash || !receiveDialogOpen) return;
|
||||
|
||||
const checkPayment = async () => {
|
||||
if (!walletInfo?.methods.includes("lookup_invoice")) return;
|
||||
if (!walletMethods.includes("lookup_invoice")) return;
|
||||
|
||||
setCheckingPayment(true);
|
||||
try {
|
||||
const result = await lookupInvoice(generatedPaymentHash);
|
||||
// If invoice is settled, close dialog and refresh
|
||||
// If invoice is settled, close dialog (notifications will refresh transactions)
|
||||
if (result.settled_at) {
|
||||
toast.success("Payment received!");
|
||||
setReceiveDialogOpen(false);
|
||||
resetReceiveDialog();
|
||||
// Reload transactions
|
||||
reloadTransactions();
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors, will retry
|
||||
@@ -571,39 +474,7 @@ export default function WalletViewer() {
|
||||
|
||||
const intervalId = setInterval(checkPayment, PAYMENT_CHECK_INTERVAL);
|
||||
return () => clearInterval(intervalId);
|
||||
}, [
|
||||
generatedPaymentHash,
|
||||
receiveDialogOpen,
|
||||
walletInfo,
|
||||
lookupInvoice,
|
||||
reloadTransactions,
|
||||
]);
|
||||
|
||||
const loadMoreTransactions = useCallback(async () => {
|
||||
if (
|
||||
!walletInfo?.methods.includes("list_transactions") ||
|
||||
!hasMore ||
|
||||
loadingMore
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoadingMore(true);
|
||||
try {
|
||||
const result = await listTransactions({
|
||||
limit: BATCH_SIZE,
|
||||
offset: transactions.length,
|
||||
});
|
||||
const newTxs = result.transactions || [];
|
||||
setTransactions((prev) => [...prev, ...newTxs]);
|
||||
setHasMore(newTxs.length === BATCH_SIZE);
|
||||
} catch (error) {
|
||||
console.error("Failed to load more transactions:", error);
|
||||
toast.error("Failed to load more transactions");
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [walletInfo, hasMore, loadingMore, transactions.length, listTransactions]);
|
||||
}, [generatedPaymentHash, receiveDialogOpen, walletMethods, lookupInvoice]);
|
||||
|
||||
async function handleRefreshBalance() {
|
||||
// Rate limiting: minimum 2 seconds between refreshes
|
||||
@@ -616,7 +487,7 @@ export default function WalletViewer() {
|
||||
}
|
||||
|
||||
lastBalanceRefreshRef.current = now;
|
||||
setLoading(true);
|
||||
setRefreshingBalance(true);
|
||||
try {
|
||||
await refreshBalance();
|
||||
toast.success("Balance refreshed");
|
||||
@@ -624,10 +495,24 @@ export default function WalletViewer() {
|
||||
console.error("Failed to refresh balance:", error);
|
||||
toast.error("Failed to refresh balance");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshingBalance(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCopyNwcString() {
|
||||
if (!state.nwcConnection) return;
|
||||
|
||||
const { service, relays, secret, lud16 } = state.nwcConnection;
|
||||
const params = new URLSearchParams();
|
||||
relays.forEach((relay) => params.append("relay", relay));
|
||||
params.append("secret", secret);
|
||||
if (lud16) params.append("lud16", lud16);
|
||||
|
||||
const nwcString = `nostr+walletconnect://${service}?${params.toString()}`;
|
||||
copyNwc(nwcString);
|
||||
toast.success("Connection string copied");
|
||||
}
|
||||
|
||||
async function handleConfirmSend() {
|
||||
if (!sendInvoice.trim()) {
|
||||
toast.error("Please enter an invoice or Lightning address");
|
||||
@@ -804,8 +689,7 @@ export default function WalletViewer() {
|
||||
toast.success("Payment sent successfully");
|
||||
resetSendDialog();
|
||||
setSendDialogOpen(false);
|
||||
// Reload transactions
|
||||
reloadTransactions();
|
||||
// Notifications will automatically refresh transactions
|
||||
} catch (error) {
|
||||
console.error("Payment failed:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Payment failed");
|
||||
@@ -868,16 +752,9 @@ export default function WalletViewer() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopyInvoice() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(generatedInvoice);
|
||||
setCopied(true);
|
||||
toast.success("Invoice copied to clipboard");
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (error) {
|
||||
console.error("Failed to copy invoice:", error);
|
||||
toast.error("Failed to copy to clipboard");
|
||||
}
|
||||
function handleCopyInvoice() {
|
||||
copyInvoice(generatedInvoice);
|
||||
toast.success("Invoice copied to clipboard");
|
||||
}
|
||||
|
||||
function resetReceiveDialog() {
|
||||
@@ -886,7 +763,6 @@ export default function WalletViewer() {
|
||||
setInvoiceQR("");
|
||||
setReceiveAmount("");
|
||||
setReceiveDescription("");
|
||||
setCopied(false);
|
||||
}
|
||||
|
||||
function handleDisconnect() {
|
||||
@@ -912,6 +788,13 @@ export default function WalletViewer() {
|
||||
return new Date(timestamp * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
// Derive values from transactionsState for convenience
|
||||
const transactions = transactionsState.items;
|
||||
const txLoading = transactionsState.loading;
|
||||
const txLoadingMore = transactionsState.loadingMore;
|
||||
const txHasMore = transactionsState.hasMore;
|
||||
const txError = transactionsState.error;
|
||||
|
||||
// Process transactions to include day markers
|
||||
const transactionsWithMarkers = useMemo(() => {
|
||||
if (!transactions || transactions.length === 0) return [];
|
||||
@@ -948,7 +831,7 @@ export default function WalletViewer() {
|
||||
return items;
|
||||
}, [transactions]);
|
||||
|
||||
if (!isConnected || !wallet) {
|
||||
if (!isConnected) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center p-8">
|
||||
<Card className="max-w-md">
|
||||
@@ -984,17 +867,39 @@ export default function WalletViewer() {
|
||||
<div className="h-full w-full flex flex-col bg-background text-foreground">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border px-4 py-2 font-mono text-xs flex items-center justify-between">
|
||||
{/* Left: Wallet Name + Status */}
|
||||
{/* Left: Wallet Name + Connection Status */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">
|
||||
{walletInfo?.alias || "Lightning Wallet"}
|
||||
{state.nwcConnection?.info?.alias || "Lightning Wallet"}
|
||||
</span>
|
||||
<div className="size-1.5 rounded-full bg-green-500" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<WalletConnectionStatus status={connectionStatus} size="sm" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{connectionStatus === "connected" && "Connected"}
|
||||
{connectionStatus === "connecting" && "Connecting..."}
|
||||
{connectionStatus === "error" && (
|
||||
<span>Error: {lastError?.message || "Connection failed"}</span>
|
||||
)}
|
||||
{connectionStatus === "disconnected" && "Disconnected"}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{connectionStatus === "error" && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-5 px-2 text-xs"
|
||||
onClick={reconnect}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Info Dropdown, Refresh, Disconnect */}
|
||||
<div className="flex items-center gap-3">
|
||||
{walletInfo && (
|
||||
{support && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@@ -1011,11 +916,11 @@ export default function WalletViewer() {
|
||||
<div className="text-xs font-semibold">
|
||||
Wallet Information
|
||||
</div>
|
||||
{walletInfo.network && (
|
||||
{state.nwcConnection?.info?.network && (
|
||||
<div className="flex justify-between text-xs">
|
||||
<span className="text-muted-foreground">Network</span>
|
||||
<span className="font-mono capitalize">
|
||||
{walletInfo.network}
|
||||
{state.nwcConnection.info.network}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -1049,7 +954,7 @@ export default function WalletViewer() {
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold">Capabilities</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{walletInfo.methods.map((method) => (
|
||||
{support.methods?.map((method) => (
|
||||
<span
|
||||
key={method}
|
||||
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-mono"
|
||||
@@ -1060,14 +965,14 @@ export default function WalletViewer() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{walletInfo.notifications &&
|
||||
walletInfo.notifications.length > 0 && (
|
||||
{support.notifications &&
|
||||
support.notifications.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold">
|
||||
Notifications
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{walletInfo.notifications.map((notification) => (
|
||||
{support.notifications.map((notification) => (
|
||||
<span
|
||||
key={notification}
|
||||
className="inline-flex items-center rounded-md bg-muted px-2 py-0.5 text-[10px] font-mono"
|
||||
@@ -1083,16 +988,33 @@ export default function WalletViewer() {
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleCopyNwcString}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors"
|
||||
aria-label="Copy connection string"
|
||||
>
|
||||
{nwcCopied ? (
|
||||
<CopyCheck className="size-3" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Copy Connection String</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleRefreshBalance}
|
||||
disabled={loading}
|
||||
disabled={refreshingBalance}
|
||||
className="flex items-center gap-1 text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
|
||||
aria-label="Refresh balance"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3 ${loading ? "animate-spin" : ""}`}
|
||||
className={`size-3 ${refreshingBalance ? "animate-spin" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
@@ -1133,42 +1055,38 @@ export default function WalletViewer() {
|
||||
</div>
|
||||
|
||||
{/* Send / Receive Buttons */}
|
||||
{walletInfo &&
|
||||
(walletInfo.methods.includes("pay_invoice") ||
|
||||
walletInfo.methods.includes("make_invoice")) && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="max-w-md mx-auto grid grid-cols-2 gap-3">
|
||||
{walletInfo.methods.includes("make_invoice") && (
|
||||
<Button
|
||||
onClick={() => setReceiveDialogOpen(true)}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="mr-2 size-4" />
|
||||
Receive
|
||||
</Button>
|
||||
)}
|
||||
{walletInfo.methods.includes("pay_invoice") && (
|
||||
<Button
|
||||
onClick={() => setSendDialogOpen(true)}
|
||||
variant="default"
|
||||
>
|
||||
<Send className="mr-2 size-4" />
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{(walletMethods.includes("pay_invoice") ||
|
||||
walletMethods.includes("make_invoice")) && (
|
||||
<div className="px-4 pb-3">
|
||||
<div className="max-w-md mx-auto grid grid-cols-2 gap-3">
|
||||
{walletMethods.includes("make_invoice") && (
|
||||
<Button
|
||||
onClick={() => setReceiveDialogOpen(true)}
|
||||
variant="outline"
|
||||
>
|
||||
<Download className="mr-2 size-4" />
|
||||
Receive
|
||||
</Button>
|
||||
)}
|
||||
{walletMethods.includes("pay_invoice") && (
|
||||
<Button onClick={() => setSendDialogOpen(true)} variant="default">
|
||||
<Send className="mr-2 size-4" />
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Transaction History */}
|
||||
<div className="flex-1 overflow-hidden flex justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
{walletInfo?.methods.includes("list_transactions") ? (
|
||||
loading ? (
|
||||
{walletMethods.includes("list_transactions") ? (
|
||||
txLoading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : txLoadFailed ? (
|
||||
) : txError ? (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 p-4">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Failed to load transaction history
|
||||
@@ -1176,7 +1094,7 @@ export default function WalletViewer() {
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={reloadTransactions}
|
||||
onClick={retryLoadTransactions}
|
||||
>
|
||||
<RefreshCw className="mr-2 size-4" />
|
||||
Retry
|
||||
@@ -1234,11 +1152,11 @@ export default function WalletViewer() {
|
||||
}}
|
||||
components={{
|
||||
Footer: () =>
|
||||
loadingMore ? (
|
||||
txLoadingMore ? (
|
||||
<div className="flex justify-center py-4 border-b border-border">
|
||||
<RefreshCw className="size-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : !hasMore && transactions.length > 0 ? (
|
||||
) : !txHasMore && transactions.length > 0 ? (
|
||||
<div className="py-4 text-center text-xs text-muted-foreground border-b border-border">
|
||||
No more transactions
|
||||
</div>
|
||||
@@ -1290,7 +1208,6 @@ export default function WalletViewer() {
|
||||
setDetailDialogOpen(open);
|
||||
if (!open) {
|
||||
setShowRawTransaction(false);
|
||||
setCopiedRawTx(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -1387,7 +1304,10 @@ export default function WalletViewer() {
|
||||
{txid}
|
||||
</p>
|
||||
<a
|
||||
href={getMempoolUrl(txid, walletInfo?.network)}
|
||||
href={getMempoolUrl(
|
||||
txid,
|
||||
state.nwcConnection?.info?.network,
|
||||
)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:text-primary/80 transition-colors flex-shrink-0"
|
||||
@@ -1453,13 +1373,11 @@ export default function WalletViewer() {
|
||||
{JSON.stringify(selectedTransaction, null, 2)}
|
||||
</pre>
|
||||
<CodeCopyButton
|
||||
copied={copiedRawTx}
|
||||
copied={rawTxCopied}
|
||||
onCopy={() => {
|
||||
navigator.clipboard.writeText(
|
||||
copyRawTx(
|
||||
JSON.stringify(selectedTransaction, null, 2),
|
||||
);
|
||||
setCopiedRawTx(true);
|
||||
setTimeout(() => setCopiedRawTx(false), 2000);
|
||||
}}
|
||||
label="Copy transaction JSON"
|
||||
/>
|
||||
@@ -1477,7 +1395,6 @@ export default function WalletViewer() {
|
||||
onClick={() => {
|
||||
setDetailDialogOpen(false);
|
||||
setShowRawTransaction(false);
|
||||
setCopiedRawTx(false);
|
||||
}}
|
||||
>
|
||||
Close
|
||||
@@ -1705,7 +1622,7 @@ export default function WalletViewer() {
|
||||
variant="default"
|
||||
className="w-full h-12"
|
||||
>
|
||||
{copied ? (
|
||||
{invoiceCopied ? (
|
||||
<>
|
||||
<Check className="mr-2 size-5" />
|
||||
Copied Invoice
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
LogOut,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import { WalletConnectionStatus } from "@/components/WalletConnectionStatus";
|
||||
import accounts from "@/services/accounts";
|
||||
import { useProfile } from "@/hooks/useProfile";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
@@ -118,18 +119,12 @@ export default function UserMenu() {
|
||||
return sats.toString();
|
||||
}
|
||||
|
||||
// Get wallet service profile for display name, using wallet relays as hints
|
||||
const walletServiceProfile = useProfile(
|
||||
nwcConnection?.service,
|
||||
nwcConnection?.relays,
|
||||
);
|
||||
|
||||
// Use wallet hook for real-time balance and methods
|
||||
// Use wallet hook for real-time balance and connection status
|
||||
const {
|
||||
disconnect: disconnectWallet,
|
||||
refreshBalance,
|
||||
balance,
|
||||
wallet,
|
||||
connectionStatus,
|
||||
} = useWallet();
|
||||
|
||||
function openProfile() {
|
||||
@@ -185,26 +180,6 @@ export default function UserMenu() {
|
||||
}
|
||||
}
|
||||
|
||||
function getWalletName(): string {
|
||||
if (!nwcConnection) return "";
|
||||
// Use service pubkey profile name, fallback to alias, then pubkey slice
|
||||
return (
|
||||
getDisplayName(nwcConnection.service, walletServiceProfile) ||
|
||||
nwcConnection.info?.alias ||
|
||||
nwcConnection.service.slice(0, 8)
|
||||
);
|
||||
}
|
||||
|
||||
function openWalletServiceProfile() {
|
||||
if (!nwcConnection?.service) return;
|
||||
addWindow(
|
||||
"profile",
|
||||
{ pubkey: nwcConnection.service },
|
||||
`Profile ${nwcConnection.service.slice(0, 8)}...`,
|
||||
);
|
||||
setShowWalletInfo(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsDialog open={showSettings} onOpenChange={setShowSettings} />
|
||||
@@ -228,8 +203,7 @@ export default function UserMenu() {
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Balance */}
|
||||
{(balance !== undefined ||
|
||||
nwcConnection.balance !== undefined) && (
|
||||
{balance !== undefined && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Balance:
|
||||
@@ -243,7 +217,7 @@ export default function UserMenu() {
|
||||
<span>
|
||||
{state.walletBalancesBlurred
|
||||
? "✦✦✦✦✦✦"
|
||||
: formatBalance(balance ?? nwcConnection.balance)}
|
||||
: formatBalance(balance)}
|
||||
</span>
|
||||
{state.walletBalancesBlurred ? (
|
||||
<EyeOff className="size-3 text-muted-foreground" />
|
||||
@@ -263,30 +237,14 @@ export default function UserMenu() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Wallet Name */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Wallet:</span>
|
||||
<button
|
||||
onClick={openWalletServiceProfile}
|
||||
className="text-sm font-medium hover:underline cursor-crosshair text-primary"
|
||||
>
|
||||
{getWalletName()}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Connection Status */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">Status:</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`size-2 rounded-full ${
|
||||
wallet ? "bg-green-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{wallet ? "Connected" : "Disconnected"}
|
||||
</span>
|
||||
</div>
|
||||
<WalletConnectionStatus
|
||||
status={connectionStatus}
|
||||
size="md"
|
||||
showLabel
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lightning Address */}
|
||||
@@ -402,25 +360,15 @@ export default function UserMenu() {
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wallet className="size-4 text-muted-foreground" />
|
||||
{balance !== undefined ||
|
||||
nwcConnection.balance !== undefined ? (
|
||||
{balance !== undefined && (
|
||||
<span className="text-sm">
|
||||
{state.walletBalancesBlurred
|
||||
? "✦✦✦✦"
|
||||
: formatBalance(balance ?? nwcConnection.balance)}
|
||||
: formatBalance(balance)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`size-1.5 rounded-full ${
|
||||
wallet ? "bg-green-500" : "bg-red-500"
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{getWalletName()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<WalletConnectionStatus status={connectionStatus} size="sm" />
|
||||
</DropdownMenuItem>
|
||||
) : (
|
||||
<DropdownMenuItem
|
||||
|
||||
@@ -1,84 +1,102 @@
|
||||
/**
|
||||
* useWallet Hook
|
||||
*
|
||||
* Provides access to the NWC wallet throughout the application.
|
||||
* Fully reactive using observables - balance updates automatically via use$()
|
||||
* Provides reactive access to the NWC wallet throughout the application.
|
||||
* All state is derived from observables - no manual synchronization needed.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { wallet, balance, payInvoice, makeInvoice } = useWallet();
|
||||
* const { wallet, balance, connectionStatus, walletMethods, payInvoice } = useWallet();
|
||||
*
|
||||
* async function handlePay() {
|
||||
* if (!wallet) return;
|
||||
* await payInvoice("lnbc...");
|
||||
* // Balance automatically updates via notifications!
|
||||
* if (connectionStatus === 'error') {
|
||||
* return <ErrorState onRetry={reconnect} />;
|
||||
* }
|
||||
*
|
||||
* return <div>Balance: {balance ? Math.floor(balance / 1000) : 0} sats</div>;
|
||||
* // walletMethods combines support$ with cached info for reliability
|
||||
* if (walletMethods.includes('pay_invoice')) {
|
||||
* return <PayButton onClick={() => payInvoice("lnbc...")} />;
|
||||
* }
|
||||
*
|
||||
* return <div>Balance: {formatSats(balance)}</div>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import type { WalletSupport } from "applesauce-wallet-connect/helpers";
|
||||
import {
|
||||
getWallet,
|
||||
wallet$,
|
||||
restoreWallet,
|
||||
clearWallet as clearWalletService,
|
||||
clearWallet,
|
||||
refreshBalance as refreshBalanceService,
|
||||
reconnect as reconnectService,
|
||||
balance$,
|
||||
connectionStatus$,
|
||||
lastError$,
|
||||
transactionsState$,
|
||||
loadTransactions as loadTransactionsService,
|
||||
loadMoreTransactions as loadMoreTransactionsService,
|
||||
retryLoadTransactions as retryLoadTransactionsService,
|
||||
} from "@/services/nwc";
|
||||
import type { WalletConnect } from "applesauce-wallet-connect";
|
||||
|
||||
export function useWallet() {
|
||||
const { state } = useGrimoire();
|
||||
const nwcConnection = state.nwcConnection;
|
||||
const [wallet, setWallet] = useState<WalletConnect | null>(getWallet());
|
||||
const restoreAttemptedRef = useRef(false);
|
||||
|
||||
// Subscribe to balance updates from observable (fully reactive!)
|
||||
// All state derived from observables
|
||||
const wallet = use$(wallet$);
|
||||
const balance = use$(balance$);
|
||||
const connectionStatus = use$(connectionStatus$);
|
||||
const lastError = use$(lastError$);
|
||||
const transactionsState = use$(transactionsState$);
|
||||
|
||||
// Initialize wallet on mount if connection exists but no wallet instance
|
||||
// Wallet support from library's support$ observable (cached by library for 60s)
|
||||
const support: WalletSupport | null | undefined = use$(
|
||||
() => wallet?.support$,
|
||||
[wallet],
|
||||
);
|
||||
|
||||
// Wallet methods - combines reactive support$ with cached info fallback
|
||||
// The support$ waits for kind 13194 events which some wallets don't publish
|
||||
const walletMethods = useMemo(() => {
|
||||
return support?.methods ?? state.nwcConnection?.info?.methods ?? [];
|
||||
}, [support?.methods, state.nwcConnection?.info?.methods]);
|
||||
|
||||
// Restore wallet on mount if connection exists
|
||||
// Note: Not awaited intentionally - wallet is available synchronously from wallet$
|
||||
// before validation completes. Any async errors are handled within restoreWallet.
|
||||
useEffect(() => {
|
||||
if (nwcConnection && !wallet) {
|
||||
console.log("[useWallet] Restoring wallet from saved connection");
|
||||
const restoredWallet = restoreWallet(nwcConnection);
|
||||
setWallet(restoredWallet);
|
||||
|
||||
// Fetch initial balance
|
||||
refreshBalanceService();
|
||||
if (nwcConnection && !wallet && !restoreAttemptedRef.current) {
|
||||
restoreAttemptedRef.current = true;
|
||||
restoreWallet(nwcConnection);
|
||||
}
|
||||
}, [nwcConnection, wallet]);
|
||||
|
||||
// Update local wallet ref when connection changes
|
||||
// Reset restore flag when connection is cleared
|
||||
useEffect(() => {
|
||||
const currentWallet = getWallet();
|
||||
if (currentWallet !== wallet) {
|
||||
setWallet(currentWallet);
|
||||
if (!nwcConnection) {
|
||||
restoreAttemptedRef.current = false;
|
||||
}
|
||||
}, [nwcConnection, wallet]);
|
||||
}, [nwcConnection]);
|
||||
|
||||
// Derived state
|
||||
const isConnected = connectionStatus !== "disconnected";
|
||||
|
||||
// ============================================================================
|
||||
// Wallet operations
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Pay a BOLT11 invoice
|
||||
* Balance will auto-update via notification subscription
|
||||
*/
|
||||
async function payInvoice(invoice: string, amount?: number) {
|
||||
if (!wallet) throw new Error("No wallet connected");
|
||||
|
||||
const result = await wallet.payInvoice(invoice, amount);
|
||||
|
||||
// Balance will update automatically via notifications
|
||||
// But we can also refresh immediately for instant feedback
|
||||
await refreshBalanceService();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a new invoice
|
||||
*/
|
||||
async function makeInvoice(
|
||||
amount: number,
|
||||
options?: {
|
||||
@@ -88,40 +106,20 @@ export function useWallet() {
|
||||
},
|
||||
) {
|
||||
if (!wallet) throw new Error("No wallet connected");
|
||||
|
||||
return await wallet.makeInvoice(amount, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get wallet info (capabilities, alias, etc.)
|
||||
*/
|
||||
async function getInfo() {
|
||||
if (!wallet) throw new Error("No wallet connected");
|
||||
|
||||
return await wallet.getInfo();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current balance
|
||||
*/
|
||||
async function getBalance() {
|
||||
if (!wallet) throw new Error("No wallet connected");
|
||||
|
||||
const result = await wallet.getBalance();
|
||||
return result.balance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually refresh the balance
|
||||
*/
|
||||
async function refreshBalance() {
|
||||
return await refreshBalanceService();
|
||||
}
|
||||
|
||||
/**
|
||||
* List recent transactions
|
||||
* @param options - Pagination and filter options
|
||||
*/
|
||||
async function listTransactions(options?: {
|
||||
from?: number;
|
||||
until?: number;
|
||||
@@ -131,69 +129,69 @@ export function useWallet() {
|
||||
type?: "incoming" | "outgoing";
|
||||
}) {
|
||||
if (!wallet) throw new Error("No wallet connected");
|
||||
|
||||
return await wallet.listTransactions(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Look up an invoice by payment hash
|
||||
* @param paymentHash - The payment hash to look up
|
||||
*/
|
||||
async function lookupInvoice(paymentHash: string) {
|
||||
if (!wallet) throw new Error("No wallet connected");
|
||||
|
||||
return await wallet.lookupInvoice(paymentHash);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay to a node pubkey directly (keysend)
|
||||
* @param pubkey - The node pubkey to pay
|
||||
* @param amount - Amount in millisats
|
||||
* @param preimage - Optional preimage (hex string)
|
||||
*/
|
||||
async function payKeysend(pubkey: string, amount: number, preimage?: string) {
|
||||
if (!wallet) throw new Error("No wallet connected");
|
||||
|
||||
const result = await wallet.payKeysend(pubkey, amount, preimage);
|
||||
|
||||
// Refresh balance after payment
|
||||
await refreshBalanceService();
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect the wallet
|
||||
*/
|
||||
function disconnect() {
|
||||
clearWalletService();
|
||||
setWallet(null);
|
||||
clearWallet();
|
||||
}
|
||||
|
||||
async function reconnect() {
|
||||
await reconnectService();
|
||||
}
|
||||
|
||||
async function refreshBalance() {
|
||||
return await refreshBalanceService();
|
||||
}
|
||||
|
||||
async function loadTransactions() {
|
||||
await loadTransactionsService();
|
||||
}
|
||||
|
||||
async function loadMoreTransactions() {
|
||||
await loadMoreTransactionsService();
|
||||
}
|
||||
|
||||
async function retryLoadTransactions() {
|
||||
await retryLoadTransactionsService();
|
||||
}
|
||||
|
||||
return {
|
||||
/** The wallet instance (null if not connected) */
|
||||
// State (all derived from observables)
|
||||
wallet,
|
||||
/** Current balance in millisats (auto-updates via observable!) */
|
||||
balance,
|
||||
/** Whether a wallet is connected */
|
||||
isConnected: !!wallet,
|
||||
/** Pay a BOLT11 invoice */
|
||||
isConnected,
|
||||
connectionStatus,
|
||||
lastError,
|
||||
support,
|
||||
walletMethods,
|
||||
transactionsState,
|
||||
|
||||
// Operations
|
||||
payInvoice,
|
||||
/** Generate a new invoice */
|
||||
makeInvoice,
|
||||
/** Get wallet information */
|
||||
getInfo,
|
||||
/** Get current balance */
|
||||
getBalance,
|
||||
/** Manually refresh balance */
|
||||
refreshBalance,
|
||||
/** List recent transactions */
|
||||
listTransactions,
|
||||
/** Look up an invoice by payment hash */
|
||||
lookupInvoice,
|
||||
/** Pay to a node pubkey directly (keysend) */
|
||||
payKeysend,
|
||||
/** Disconnect wallet */
|
||||
disconnect,
|
||||
reconnect,
|
||||
loadTransactions,
|
||||
loadMoreTransactions,
|
||||
retryLoadTransactions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,33 +4,65 @@
|
||||
* Provides a singleton WalletConnect instance for the application using
|
||||
* applesauce-wallet-connect for NIP-47 Lightning wallet integration.
|
||||
*
|
||||
* Features:
|
||||
* - Maintains persistent wallet connection across app lifetime
|
||||
* - Subscribes to NIP-47 notifications (kind 23197) for balance updates
|
||||
* - Fully reactive using RxJS observables (no polling!)
|
||||
* - Components use use$() to reactively subscribe to balance changes
|
||||
* Architecture:
|
||||
* - All state is exposed via BehaviorSubject observables
|
||||
* - Components subscribe via use$() for automatic updates
|
||||
* - Notification subscription handles balance updates reactively
|
||||
* - Automatic retry with exponential backoff on failures
|
||||
*/
|
||||
|
||||
import { WalletConnect } from "applesauce-wallet-connect";
|
||||
import type { NWCConnection } from "@/types/app";
|
||||
import {
|
||||
type TransactionsState,
|
||||
INITIAL_TRANSACTIONS_STATE,
|
||||
} from "@/types/wallet";
|
||||
import pool from "./relay-pool";
|
||||
import { BehaviorSubject, Subscription } from "rxjs";
|
||||
import { BehaviorSubject, Subscription, firstValueFrom, timeout } from "rxjs";
|
||||
|
||||
// Set the pool for wallet connect to use
|
||||
// Configure the pool for wallet connect
|
||||
WalletConnect.pool = pool;
|
||||
|
||||
let walletInstance: WalletConnect | null = null;
|
||||
// Internal state
|
||||
let notificationSubscription: Subscription | null = null;
|
||||
let notificationRetryTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/**
|
||||
* Observable for wallet balance updates
|
||||
* Components can subscribe to this for real-time balance changes using use$()
|
||||
* Connection status for the NWC wallet
|
||||
*/
|
||||
export type NWCConnectionStatus =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "error";
|
||||
|
||||
// ============================================================================
|
||||
// Observables - All state is exposed reactively
|
||||
// ============================================================================
|
||||
|
||||
/** The current wallet instance (null if not connected) */
|
||||
export const wallet$ = new BehaviorSubject<WalletConnect | null>(null);
|
||||
|
||||
/** Connection status */
|
||||
export const connectionStatus$ = new BehaviorSubject<NWCConnectionStatus>(
|
||||
"disconnected",
|
||||
);
|
||||
|
||||
/** Last connection error (null if no error) */
|
||||
export const lastError$ = new BehaviorSubject<Error | null>(null);
|
||||
|
||||
/** Current balance in millisats */
|
||||
export const balance$ = new BehaviorSubject<number | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Helper to convert hex string to Uint8Array
|
||||
*/
|
||||
/** Transaction list state (lazy loaded) */
|
||||
export const transactionsState$ = new BehaviorSubject<TransactionsState>(
|
||||
INITIAL_TRANSACTIONS_STATE,
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// Internal helpers
|
||||
// ============================================================================
|
||||
|
||||
function hexToBytes(hex: string): Uint8Array {
|
||||
const bytes = new Uint8Array(hex.length / 2);
|
||||
for (let i = 0; i < hex.length; i += 2) {
|
||||
@@ -40,111 +72,383 @@ function hexToBytes(hex: string): Uint8Array {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to wallet notifications (NIP-47 kind 23197)
|
||||
* This enables real-time balance updates when transactions occur
|
||||
* Subscribe to wallet notifications with automatic retry on error.
|
||||
* Notifications trigger balance refresh for real-time updates.
|
||||
*/
|
||||
function subscribeToNotifications(wallet: WalletConnect) {
|
||||
// Clean up existing subscription
|
||||
if (notificationSubscription) {
|
||||
notificationSubscription.unsubscribe();
|
||||
// Clean up existing subscription and pending retry
|
||||
notificationSubscription?.unsubscribe();
|
||||
notificationSubscription = null;
|
||||
if (notificationRetryTimeout) {
|
||||
clearTimeout(notificationRetryTimeout);
|
||||
notificationRetryTimeout = null;
|
||||
}
|
||||
|
||||
console.log("[NWC] Subscribing to wallet notifications");
|
||||
let retryCount = 0;
|
||||
const maxRetries = 5;
|
||||
const baseDelay = 2000;
|
||||
|
||||
// Subscribe to the wallet's notifications$ observable
|
||||
// This receives events like payment_received, payment_sent, etc.
|
||||
notificationSubscription = wallet.notifications$.subscribe({
|
||||
next: (notification) => {
|
||||
console.log("[NWC] Notification received:", notification);
|
||||
function subscribe() {
|
||||
notificationSubscription = wallet.notifications$.subscribe({
|
||||
next: (notification) => {
|
||||
console.log(
|
||||
"[NWC] Notification received:",
|
||||
notification.notification_type,
|
||||
);
|
||||
retryCount = 0;
|
||||
|
||||
// When we get a notification, refresh the balance
|
||||
// The notification types include: payment_received, payment_sent, etc.
|
||||
wallet
|
||||
.getBalance()
|
||||
.then((result) => {
|
||||
const newBalance = result.balance;
|
||||
if (balance$.value !== newBalance) {
|
||||
balance$.next(newBalance);
|
||||
console.log("[NWC] Balance updated from notification:", newBalance);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
"[NWC] Failed to fetch balance after notification:",
|
||||
error,
|
||||
// Recover from error state on successful notification
|
||||
if (connectionStatus$.value === "error") {
|
||||
connectionStatus$.next("connected");
|
||||
lastError$.next(null);
|
||||
}
|
||||
|
||||
// Refresh balance and transactions on any notification
|
||||
refreshBalance();
|
||||
refreshTransactions();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("[NWC] Notification error:", error);
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
retryCount++;
|
||||
connectionStatus$.next("connecting");
|
||||
notificationRetryTimeout = setTimeout(subscribe, delay);
|
||||
} else {
|
||||
connectionStatus$.next("error");
|
||||
lastError$.next(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Notification subscription failed"),
|
||||
);
|
||||
});
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("[NWC] Notification subscription error:", error);
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
// Reconnect if subscription completes unexpectedly
|
||||
if (wallet$.value && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
retryCount++;
|
||||
notificationRetryTimeout = setTimeout(subscribe, delay);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
subscribe();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Public API
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Creates a new WalletConnect instance from a connection string
|
||||
* Automatically subscribes to notifications for balance updates
|
||||
* Creates a new wallet connection from a NWC URI.
|
||||
* Used when user connects a new wallet.
|
||||
*/
|
||||
export function createWalletFromURI(connectionString: string): WalletConnect {
|
||||
walletInstance = WalletConnect.fromConnectURI(connectionString);
|
||||
subscribeToNotifications(walletInstance);
|
||||
return walletInstance;
|
||||
connectionStatus$.next("connecting");
|
||||
lastError$.next(null);
|
||||
|
||||
const wallet = WalletConnect.fromConnectURI(connectionString);
|
||||
wallet$.next(wallet);
|
||||
|
||||
subscribeToNotifications(wallet);
|
||||
refreshBalance(); // Fetch initial balance
|
||||
|
||||
return wallet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a wallet from saved connection data
|
||||
* Used on app startup to reconnect to a previously connected wallet
|
||||
* Restores a wallet from saved connection data.
|
||||
* Validates the connection before marking as connected.
|
||||
*/
|
||||
export function restoreWallet(connection: NWCConnection): WalletConnect {
|
||||
walletInstance = new WalletConnect({
|
||||
export async function restoreWallet(
|
||||
connection: NWCConnection,
|
||||
): Promise<WalletConnect> {
|
||||
connectionStatus$.next("connecting");
|
||||
lastError$.next(null);
|
||||
|
||||
const wallet = new WalletConnect({
|
||||
service: connection.service,
|
||||
relays: connection.relays,
|
||||
secret: hexToBytes(connection.secret),
|
||||
});
|
||||
|
||||
// Set initial balance from cache
|
||||
wallet$.next(wallet);
|
||||
|
||||
// Show cached balance immediately while validating
|
||||
if (connection.balance !== undefined) {
|
||||
balance$.next(connection.balance);
|
||||
}
|
||||
|
||||
subscribeToNotifications(walletInstance);
|
||||
return walletInstance;
|
||||
// Validate connection by waiting for support info
|
||||
try {
|
||||
await firstValueFrom(
|
||||
wallet.support$.pipe(
|
||||
timeout({
|
||||
first: 10000,
|
||||
with: () => {
|
||||
throw new Error("Connection timeout");
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
connectionStatus$.next("connected");
|
||||
} catch (error) {
|
||||
console.error("[NWC] Validation failed:", error);
|
||||
connectionStatus$.next("error");
|
||||
lastError$.next(
|
||||
error instanceof Error ? error : new Error("Connection failed"),
|
||||
);
|
||||
// Continue anyway - notifications will retry
|
||||
}
|
||||
|
||||
subscribeToNotifications(wallet);
|
||||
refreshBalance();
|
||||
|
||||
return wallet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current wallet instance
|
||||
*/
|
||||
export function getWallet(): WalletConnect | null {
|
||||
return walletInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the current wallet instance and stops notifications
|
||||
* Disconnects and clears the wallet.
|
||||
*/
|
||||
export function clearWallet(): void {
|
||||
if (notificationSubscription) {
|
||||
notificationSubscription.unsubscribe();
|
||||
notificationSubscription = null;
|
||||
// Clean up subscription and pending retry
|
||||
notificationSubscription?.unsubscribe();
|
||||
notificationSubscription = null;
|
||||
if (notificationRetryTimeout) {
|
||||
clearTimeout(notificationRetryTimeout);
|
||||
notificationRetryTimeout = null;
|
||||
}
|
||||
walletInstance = null;
|
||||
|
||||
wallet$.next(null);
|
||||
balance$.next(undefined);
|
||||
connectionStatus$.next("disconnected");
|
||||
lastError$.next(null);
|
||||
resetTransactions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually refresh the balance from the wallet
|
||||
* Useful for initial load or manual refresh button
|
||||
* Refreshes the balance from the wallet.
|
||||
* Includes retry logic with exponential backoff for reliability.
|
||||
*
|
||||
* Note: If we're already connected and a balance fetch fails after retries,
|
||||
* we don't set error state. This prevents UI flapping - the notification
|
||||
* subscription is the primary health indicator. A transient balance fetch
|
||||
* failure shouldn't mark an otherwise working connection as errored.
|
||||
*/
|
||||
export async function refreshBalance(): Promise<number | undefined> {
|
||||
if (!walletInstance) return undefined;
|
||||
const wallet = wallet$.value;
|
||||
if (!wallet) return undefined;
|
||||
|
||||
const maxRetries = 3;
|
||||
const baseDelay = 1000;
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await wallet.getBalance();
|
||||
balance$.next(result.balance);
|
||||
|
||||
// Recover from error state on success
|
||||
if (connectionStatus$.value === "error") {
|
||||
connectionStatus$.next("connected");
|
||||
lastError$.next(null);
|
||||
}
|
||||
|
||||
return result.balance;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[NWC] Balance refresh failed (attempt ${attempt + 1}):`,
|
||||
error,
|
||||
);
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
await new Promise((r) =>
|
||||
setTimeout(r, baseDelay * Math.pow(2, attempt)),
|
||||
);
|
||||
} else if (connectionStatus$.value !== "connected") {
|
||||
// Only set error state if not already connected (e.g., during initial validation)
|
||||
connectionStatus$.next("error");
|
||||
lastError$.next(
|
||||
error instanceof Error ? error : new Error("Failed to get balance"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to reconnect after an error.
|
||||
*/
|
||||
export async function reconnect(): Promise<void> {
|
||||
const wallet = wallet$.value;
|
||||
if (!wallet) return;
|
||||
|
||||
connectionStatus$.next("connecting");
|
||||
lastError$.next(null);
|
||||
|
||||
subscribeToNotifications(wallet);
|
||||
await refreshBalance();
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Transaction loading (lazy, paginated)
|
||||
// ============================================================================
|
||||
|
||||
const TRANSACTIONS_PAGE_SIZE = 20;
|
||||
|
||||
/**
|
||||
* Loads the initial batch of transactions.
|
||||
* Only loads if not already initialized (lazy loading).
|
||||
*/
|
||||
export async function loadTransactions(): Promise<void> {
|
||||
const wallet = wallet$.value;
|
||||
if (!wallet) return;
|
||||
|
||||
const current = transactionsState$.value;
|
||||
|
||||
// Skip if already loading or initialized
|
||||
if (current.loading || current.initialized) return;
|
||||
|
||||
transactionsState$.next({
|
||||
...current,
|
||||
loading: true,
|
||||
error: null,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await walletInstance.getBalance();
|
||||
const newBalance = result.balance;
|
||||
const result = await wallet.listTransactions({
|
||||
limit: TRANSACTIONS_PAGE_SIZE,
|
||||
});
|
||||
|
||||
balance$.next(newBalance);
|
||||
return newBalance;
|
||||
transactionsState$.next({
|
||||
items: result.transactions,
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
hasMore: result.transactions.length >= TRANSACTIONS_PAGE_SIZE,
|
||||
error: null,
|
||||
initialized: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[NWC] Failed to refresh balance:", error);
|
||||
return undefined;
|
||||
console.error("[NWC] Failed to load transactions:", error);
|
||||
transactionsState$.next({
|
||||
...transactionsState$.value,
|
||||
loading: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Failed to load transactions"),
|
||||
initialized: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads more transactions (pagination).
|
||||
*/
|
||||
export async function loadMoreTransactions(): Promise<void> {
|
||||
const wallet = wallet$.value;
|
||||
if (!wallet) return;
|
||||
|
||||
const current = transactionsState$.value;
|
||||
|
||||
// Skip if already loading or no more to load
|
||||
if (current.loading || current.loadingMore || !current.hasMore) return;
|
||||
|
||||
transactionsState$.next({
|
||||
...current,
|
||||
loadingMore: true,
|
||||
});
|
||||
|
||||
try {
|
||||
// Get the oldest transaction timestamp for pagination
|
||||
const oldestTx = current.items[current.items.length - 1];
|
||||
const until = oldestTx?.created_at;
|
||||
|
||||
const result = await wallet.listTransactions({
|
||||
limit: TRANSACTIONS_PAGE_SIZE,
|
||||
until,
|
||||
});
|
||||
|
||||
// Filter out any duplicates (in case of overlapping timestamps)
|
||||
const existingHashes = new Set(current.items.map((tx) => tx.payment_hash));
|
||||
const newTransactions = result.transactions.filter(
|
||||
(tx) => !existingHashes.has(tx.payment_hash),
|
||||
);
|
||||
|
||||
transactionsState$.next({
|
||||
...current,
|
||||
items: [...current.items, ...newTransactions],
|
||||
loadingMore: false,
|
||||
hasMore: result.transactions.length >= TRANSACTIONS_PAGE_SIZE,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[NWC] Failed to load more transactions:", error);
|
||||
transactionsState$.next({
|
||||
...current,
|
||||
loadingMore: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Failed to load more transactions"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the transaction list (prepends new transactions).
|
||||
* Called automatically on payment notifications.
|
||||
*/
|
||||
export async function refreshTransactions(): Promise<void> {
|
||||
const wallet = wallet$.value;
|
||||
if (!wallet) return;
|
||||
|
||||
const current = transactionsState$.value;
|
||||
|
||||
// Only refresh if already initialized
|
||||
if (!current.initialized) return;
|
||||
|
||||
try {
|
||||
// Get the newest transaction timestamp
|
||||
const newestTx = current.items[0];
|
||||
const from = newestTx?.created_at ? newestTx.created_at + 1 : undefined;
|
||||
|
||||
const result = await wallet.listTransactions({
|
||||
limit: TRANSACTIONS_PAGE_SIZE,
|
||||
from,
|
||||
});
|
||||
|
||||
// Filter out duplicates and prepend new transactions
|
||||
const existingHashes = new Set(current.items.map((tx) => tx.payment_hash));
|
||||
const newTransactions = result.transactions.filter(
|
||||
(tx) => !existingHashes.has(tx.payment_hash),
|
||||
);
|
||||
|
||||
if (newTransactions.length > 0) {
|
||||
transactionsState$.next({
|
||||
...current,
|
||||
items: [...newTransactions, ...current.items],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[NWC] Failed to refresh transactions:", error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets transaction state (called on wallet clear).
|
||||
*/
|
||||
function resetTransactions(): void {
|
||||
transactionsState$.next(INITIAL_TRANSACTIONS_STATE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Force reload transactions (used for retry after error).
|
||||
*/
|
||||
export async function retryLoadTransactions(): Promise<void> {
|
||||
resetTransactions();
|
||||
await loadTransactions();
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface NWCConnection {
|
||||
/** Optional wallet info */
|
||||
info?: {
|
||||
alias?: string;
|
||||
network?: string;
|
||||
methods?: string[];
|
||||
notifications?: string[];
|
||||
};
|
||||
|
||||
51
src/types/wallet.ts
Normal file
51
src/types/wallet.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Wallet-related type definitions for NWC (NIP-47)
|
||||
*/
|
||||
|
||||
/**
|
||||
* A Lightning/Bitcoin transaction from NWC list_transactions
|
||||
*/
|
||||
export interface Transaction {
|
||||
type: "incoming" | "outgoing";
|
||||
invoice?: string;
|
||||
description?: string;
|
||||
description_hash?: string;
|
||||
preimage?: string;
|
||||
payment_hash?: string;
|
||||
amount: number;
|
||||
fees_paid?: number;
|
||||
created_at: number;
|
||||
expires_at?: number;
|
||||
settled_at?: number;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* State for the transactions observable
|
||||
*/
|
||||
export interface TransactionsState {
|
||||
/** The list of transactions */
|
||||
items: Transaction[];
|
||||
/** Whether we're loading the initial batch */
|
||||
loading: boolean;
|
||||
/** Whether we're loading more (pagination) */
|
||||
loadingMore: boolean;
|
||||
/** Whether there are more transactions to load */
|
||||
hasMore: boolean;
|
||||
/** Error from last load attempt */
|
||||
error: Error | null;
|
||||
/** Whether initial load has been triggered */
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initial state for transactions
|
||||
*/
|
||||
export const INITIAL_TRANSACTIONS_STATE: TransactionsState = {
|
||||
items: [],
|
||||
loading: false,
|
||||
loadingMore: false,
|
||||
hasMore: true,
|
||||
error: null,
|
||||
initialized: false,
|
||||
};
|
||||
Reference in New Issue
Block a user