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:
Alejandro
2026-01-29 18:02:40 +01:00
committed by GitHub
parent 9b883f1173
commit f3cc7779e3
8 changed files with 751 additions and 463 deletions

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 { 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,
});

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

View File

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

View File

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

View File

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

View File

@@ -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();
}

View File

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