mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-12 00:17:02 +02:00
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
This commit is contained in:
@@ -83,6 +83,7 @@ export default function ConnectWalletDialog({
|
||||
balance,
|
||||
info: {
|
||||
alias: info.alias,
|
||||
network: info.network,
|
||||
methods: info.methods,
|
||||
notifications: info.notifications,
|
||||
},
|
||||
@@ -96,6 +97,7 @@ export default function ConnectWalletDialog({
|
||||
// Update info
|
||||
updateNWCInfo({
|
||||
alias: info.alias,
|
||||
network: info.network,
|
||||
methods: info.methods,
|
||||
notifications: info.notifications,
|
||||
});
|
||||
|
||||
@@ -76,17 +76,6 @@ interface Transaction {
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
interface WalletInfo {
|
||||
alias?: string;
|
||||
color?: string;
|
||||
pubkey?: string;
|
||||
network?: string;
|
||||
block_height?: number;
|
||||
block_hash?: string;
|
||||
methods: string[];
|
||||
notifications?: string[];
|
||||
}
|
||||
|
||||
interface InvoiceDetails {
|
||||
amount?: number;
|
||||
description?: string;
|
||||
@@ -409,16 +398,18 @@ export default function WalletViewer() {
|
||||
wallet,
|
||||
balance,
|
||||
isConnected,
|
||||
getInfo,
|
||||
connectionStatus,
|
||||
lastError,
|
||||
support, // Wallet capabilities from support$ observable (cached by library)
|
||||
refreshBalance,
|
||||
listTransactions,
|
||||
makeInvoice,
|
||||
payInvoice,
|
||||
lookupInvoice,
|
||||
disconnect,
|
||||
reconnect,
|
||||
} = useWallet();
|
||||
|
||||
const [walletInfo, setWalletInfo] = useState<WalletInfo | null>(null);
|
||||
const [transactions, setTransactions] = useState<Transaction[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
@@ -429,7 +420,6 @@ export default function WalletViewer() {
|
||||
const [txLoadFailed, setTxLoadFailed] = useState(false);
|
||||
|
||||
// Use refs to track loading attempts without causing re-renders
|
||||
const walletInfoLoadedRef = useRef(false);
|
||||
const lastConnectionStateRef = useRef(isConnected);
|
||||
const lastBalanceRefreshRef = useRef(0);
|
||||
const lastTxLoadRef = useRef(0);
|
||||
@@ -462,48 +452,34 @@ export default function WalletViewer() {
|
||||
const [showRawTransaction, setShowRawTransaction] = useState(false);
|
||||
const [copiedRawTx, setCopiedRawTx] = useState(false);
|
||||
|
||||
// Load wallet info when connected
|
||||
// Reset state when connection changes
|
||||
useEffect(() => {
|
||||
// Detect connection state changes
|
||||
if (isConnected !== lastConnectionStateRef.current) {
|
||||
lastConnectionStateRef.current = isConnected;
|
||||
walletInfoLoadedRef.current = false;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, [isConnected]);
|
||||
|
||||
// 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)
|
||||
// Load transactions when wallet support is available (only once)
|
||||
// support comes from the library's support$ observable (cached)
|
||||
useEffect(() => {
|
||||
if (
|
||||
walletInfo?.methods.includes("list_transactions") &&
|
||||
support?.methods?.includes("list_transactions") &&
|
||||
!txLoadAttempted &&
|
||||
!loading
|
||||
) {
|
||||
@@ -527,7 +503,7 @@ export default function WalletViewer() {
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [walletInfo, txLoadAttempted, loading, listTransactions]);
|
||||
}, [support, txLoadAttempted, loading, listTransactions]);
|
||||
|
||||
// Helper to reload transactions (resets flags to trigger reload)
|
||||
const reloadTransactions = useCallback(() => {
|
||||
@@ -549,7 +525,7 @@ export default function WalletViewer() {
|
||||
if (!generatedPaymentHash || !receiveDialogOpen) return;
|
||||
|
||||
const checkPayment = async () => {
|
||||
if (!walletInfo?.methods.includes("lookup_invoice")) return;
|
||||
if (!support?.methods?.includes("lookup_invoice")) return;
|
||||
|
||||
setCheckingPayment(true);
|
||||
try {
|
||||
@@ -574,14 +550,14 @@ export default function WalletViewer() {
|
||||
}, [
|
||||
generatedPaymentHash,
|
||||
receiveDialogOpen,
|
||||
walletInfo,
|
||||
support,
|
||||
lookupInvoice,
|
||||
reloadTransactions,
|
||||
]);
|
||||
|
||||
const loadMoreTransactions = useCallback(async () => {
|
||||
if (
|
||||
!walletInfo?.methods.includes("list_transactions") ||
|
||||
!support?.methods?.includes("list_transactions") ||
|
||||
!hasMore ||
|
||||
loadingMore
|
||||
) {
|
||||
@@ -603,7 +579,7 @@ export default function WalletViewer() {
|
||||
} finally {
|
||||
setLoadingMore(false);
|
||||
}
|
||||
}, [walletInfo, hasMore, loadingMore, transactions.length, listTransactions]);
|
||||
}, [support, hasMore, loadingMore, transactions.length, listTransactions]);
|
||||
|
||||
async function handleRefreshBalance() {
|
||||
// Rate limiting: minimum 2 seconds between refreshes
|
||||
@@ -984,17 +960,49 @@ 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>
|
||||
<div
|
||||
className={`size-1.5 rounded-full ${
|
||||
connectionStatus === "connected"
|
||||
? "bg-green-500"
|
||||
: connectionStatus === "connecting"
|
||||
? "bg-yellow-500 animate-pulse"
|
||||
: connectionStatus === "error"
|
||||
? "bg-red-500"
|
||||
: "bg-gray-500"
|
||||
}`}
|
||||
/>
|
||||
</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 +1019,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 +1057,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 +1068,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"
|
||||
@@ -1133,12 +1141,12 @@ export default function WalletViewer() {
|
||||
</div>
|
||||
|
||||
{/* Send / Receive Buttons */}
|
||||
{walletInfo &&
|
||||
(walletInfo.methods.includes("pay_invoice") ||
|
||||
walletInfo.methods.includes("make_invoice")) && (
|
||||
{support &&
|
||||
(support.methods?.includes("pay_invoice") ||
|
||||
support.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") && (
|
||||
{support.methods?.includes("make_invoice") && (
|
||||
<Button
|
||||
onClick={() => setReceiveDialogOpen(true)}
|
||||
variant="outline"
|
||||
@@ -1147,7 +1155,7 @@ export default function WalletViewer() {
|
||||
Receive
|
||||
</Button>
|
||||
)}
|
||||
{walletInfo.methods.includes("pay_invoice") && (
|
||||
{support.methods?.includes("pay_invoice") && (
|
||||
<Button
|
||||
onClick={() => setSendDialogOpen(true)}
|
||||
variant="default"
|
||||
@@ -1163,7 +1171,7 @@ export default function WalletViewer() {
|
||||
{/* Transaction History */}
|
||||
<div className="flex-1 overflow-hidden flex justify-center">
|
||||
<div className="w-full max-w-md">
|
||||
{walletInfo?.methods.includes("list_transactions") ? (
|
||||
{support?.methods?.includes("list_transactions") ? (
|
||||
loading ? (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<RefreshCw className="size-6 animate-spin text-muted-foreground" />
|
||||
@@ -1387,7 +1395,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"
|
||||
|
||||
@@ -7,7 +7,15 @@
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { wallet, balance, payInvoice, makeInvoice } = useWallet();
|
||||
* const { wallet, balance, connectionStatus, support, payInvoice } = useWallet();
|
||||
*
|
||||
* // Connection status: 'disconnected' | 'connecting' | 'connected' | 'error'
|
||||
* if (connectionStatus === 'error') {
|
||||
* return <div>Connection error - <button onClick={reconnect}>Retry</button></div>;
|
||||
* }
|
||||
*
|
||||
* // Wallet capabilities from support$ observable (cached by library)
|
||||
* const canPay = support?.methods?.includes('pay_invoice');
|
||||
*
|
||||
* async function handlePay() {
|
||||
* if (!wallet) return;
|
||||
@@ -20,7 +28,7 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { use$ } from "applesauce-react/hooks";
|
||||
import { useGrimoire } from "@/core/state";
|
||||
import {
|
||||
@@ -28,7 +36,10 @@ import {
|
||||
restoreWallet,
|
||||
clearWallet as clearWalletService,
|
||||
refreshBalance as refreshBalanceService,
|
||||
reconnect as reconnectService,
|
||||
balance$,
|
||||
connectionStatus$,
|
||||
lastError$,
|
||||
} from "@/services/nwc";
|
||||
import type { WalletConnect } from "applesauce-wallet-connect";
|
||||
|
||||
@@ -36,22 +47,37 @@ 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!)
|
||||
// Subscribe to observables (fully reactive!)
|
||||
const balance = use$(balance$);
|
||||
const connectionStatus = use$(connectionStatus$);
|
||||
const lastError = use$(lastError$);
|
||||
|
||||
// Subscribe to wallet support$ observable for cached capabilities
|
||||
// This replaces manual getInfo() calls
|
||||
const support = use$(() => wallet?.support$, [wallet]);
|
||||
|
||||
// Initialize wallet on mount if connection exists but no wallet instance
|
||||
useEffect(() => {
|
||||
if (nwcConnection && !wallet) {
|
||||
if (nwcConnection && !wallet && !restoreAttemptedRef.current) {
|
||||
restoreAttemptedRef.current = true;
|
||||
console.log("[useWallet] Restoring wallet from saved connection");
|
||||
const restoredWallet = restoreWallet(nwcConnection);
|
||||
setWallet(restoredWallet);
|
||||
|
||||
// Fetch initial balance
|
||||
refreshBalanceService();
|
||||
// restoreWallet is now async and validates the connection
|
||||
restoreWallet(nwcConnection).then((restoredWallet) => {
|
||||
setWallet(restoredWallet);
|
||||
});
|
||||
}
|
||||
}, [nwcConnection, wallet]);
|
||||
|
||||
// Reset restore flag when connection is cleared
|
||||
useEffect(() => {
|
||||
if (!nwcConnection) {
|
||||
restoreAttemptedRef.current = false;
|
||||
}
|
||||
}, [nwcConnection]);
|
||||
|
||||
// Update local wallet ref when connection changes
|
||||
useEffect(() => {
|
||||
const currentWallet = getWallet();
|
||||
@@ -170,6 +196,13 @@ export function useWallet() {
|
||||
setWallet(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconnect the wallet after an error
|
||||
*/
|
||||
async function reconnect() {
|
||||
await reconnectService();
|
||||
}
|
||||
|
||||
return {
|
||||
/** The wallet instance (null if not connected) */
|
||||
wallet,
|
||||
@@ -177,11 +210,17 @@ export function useWallet() {
|
||||
balance,
|
||||
/** Whether a wallet is connected */
|
||||
isConnected: !!wallet,
|
||||
/** Connection status: 'disconnected' | 'connecting' | 'connected' | 'error' */
|
||||
connectionStatus,
|
||||
/** The last connection error (null if no error) */
|
||||
lastError,
|
||||
/** Wallet support info from support$ observable (methods, notifications, etc.) - cached by library */
|
||||
support,
|
||||
/** Pay a BOLT11 invoice */
|
||||
payInvoice,
|
||||
/** Generate a new invoice */
|
||||
makeInvoice,
|
||||
/** Get wallet information */
|
||||
/** Get wallet information - prefer using support instead */
|
||||
getInfo,
|
||||
/** Get current balance */
|
||||
getBalance,
|
||||
@@ -195,5 +234,7 @@ export function useWallet() {
|
||||
payKeysend,
|
||||
/** Disconnect wallet */
|
||||
disconnect,
|
||||
/** Attempt to reconnect after an error */
|
||||
reconnect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,18 +9,48 @@
|
||||
* - 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
|
||||
* - Connection health tracking with automatic recovery
|
||||
* - Uses library's support$ observable for cached wallet capabilities
|
||||
*/
|
||||
|
||||
import { WalletConnect } from "applesauce-wallet-connect";
|
||||
import type { NWCConnection } from "@/types/app";
|
||||
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
|
||||
WalletConnect.pool = pool;
|
||||
|
||||
let walletInstance: WalletConnect | null = null;
|
||||
let notificationSubscription: Subscription | null = null;
|
||||
let supportSubscription: Subscription | null = null;
|
||||
|
||||
/**
|
||||
* Connection status for the NWC wallet
|
||||
* - disconnected: No wallet connected
|
||||
* - connecting: Wallet is being restored/validated
|
||||
* - connected: Wallet is connected and responding
|
||||
* - error: Connection failed or lost
|
||||
*/
|
||||
export type NWCConnectionStatus =
|
||||
| "disconnected"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "error";
|
||||
|
||||
/**
|
||||
* Observable for connection status
|
||||
* Components can subscribe to this for real-time connection state using use$()
|
||||
*/
|
||||
export const connectionStatus$ = new BehaviorSubject<NWCConnectionStatus>(
|
||||
"disconnected",
|
||||
);
|
||||
|
||||
/**
|
||||
* Observable for the last connection error
|
||||
* Components can use this to display error messages
|
||||
*/
|
||||
export const lastError$ = new BehaviorSubject<Error | null>(null);
|
||||
|
||||
/**
|
||||
* Observable for wallet balance updates
|
||||
@@ -40,43 +70,104 @@ function hexToBytes(hex: string): Uint8Array {
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to wallet notifications (NIP-47 kind 23197)
|
||||
* Subscribe to wallet notifications with automatic retry on error
|
||||
* This enables real-time balance updates when transactions occur
|
||||
*/
|
||||
function subscribeToNotifications(wallet: WalletConnect) {
|
||||
function subscribeToNotificationsWithRetry(wallet: WalletConnect) {
|
||||
// Clean up existing subscription
|
||||
if (notificationSubscription) {
|
||||
notificationSubscription.unsubscribe();
|
||||
notificationSubscription = null;
|
||||
}
|
||||
|
||||
console.log("[NWC] Subscribing to wallet notifications");
|
||||
let retryCount = 0;
|
||||
const maxRetries = 5;
|
||||
const baseDelay = 2000; // 2 seconds
|
||||
|
||||
// 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() {
|
||||
console.log(
|
||||
`[NWC] Subscribing to wallet notifications (attempt ${retryCount + 1})`,
|
||||
);
|
||||
|
||||
// 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,
|
||||
notificationSubscription = wallet.notifications$.subscribe({
|
||||
next: (notification) => {
|
||||
console.log("[NWC] Notification received:", notification);
|
||||
retryCount = 0; // Reset retry count on success
|
||||
|
||||
// Mark as connected if we were in error state
|
||||
if (connectionStatus$.value === "error") {
|
||||
connectionStatus$.next("connected");
|
||||
lastError$.next(null);
|
||||
}
|
||||
|
||||
// When we get a notification, refresh the balance
|
||||
refreshBalance();
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("[NWC] Notification subscription error:", error);
|
||||
|
||||
if (retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
retryCount++;
|
||||
console.log(`[NWC] Retrying notification subscription in ${delay}ms`);
|
||||
connectionStatus$.next("connecting");
|
||||
setTimeout(subscribe, delay);
|
||||
} else {
|
||||
console.error("[NWC] Max notification retries reached");
|
||||
connectionStatus$.next("error");
|
||||
lastError$.next(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Notification subscription failed"),
|
||||
);
|
||||
});
|
||||
}
|
||||
},
|
||||
complete: () => {
|
||||
console.log("[NWC] Notification subscription completed");
|
||||
// If subscription completes unexpectedly, try to reconnect
|
||||
if (walletInstance && retryCount < maxRetries) {
|
||||
const delay = baseDelay * Math.pow(2, retryCount);
|
||||
retryCount++;
|
||||
console.log(
|
||||
`[NWC] Subscription completed, reconnecting in ${delay}ms`,
|
||||
);
|
||||
setTimeout(subscribe, delay);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
subscribe();
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to the wallet's support$ observable for cached capabilities
|
||||
* This keeps connection alive and validates the wallet is responding
|
||||
*/
|
||||
function subscribeToSupport(wallet: WalletConnect) {
|
||||
// Clean up existing subscription
|
||||
if (supportSubscription) {
|
||||
supportSubscription.unsubscribe();
|
||||
supportSubscription = null;
|
||||
}
|
||||
|
||||
supportSubscription = wallet.support$.subscribe({
|
||||
next: (support) => {
|
||||
if (support) {
|
||||
console.log("[NWC] Wallet support info received:", support);
|
||||
// Mark as connected when we receive support info
|
||||
if (
|
||||
connectionStatus$.value === "connecting" ||
|
||||
connectionStatus$.value === "error"
|
||||
) {
|
||||
connectionStatus$.next("connected");
|
||||
lastError$.next(null);
|
||||
}
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
console.error("[NWC] Notification subscription error:", error);
|
||||
console.error("[NWC] Support subscription error:", error);
|
||||
// Don't set error state here as notifications subscription handles recovery
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -86,28 +177,73 @@ function subscribeToNotifications(wallet: WalletConnect) {
|
||||
* Automatically subscribes to notifications for balance updates
|
||||
*/
|
||||
export function createWalletFromURI(connectionString: string): WalletConnect {
|
||||
connectionStatus$.next("connecting");
|
||||
lastError$.next(null);
|
||||
|
||||
walletInstance = WalletConnect.fromConnectURI(connectionString);
|
||||
subscribeToNotifications(walletInstance);
|
||||
subscribeToSupport(walletInstance);
|
||||
subscribeToNotificationsWithRetry(walletInstance);
|
||||
|
||||
return walletInstance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores a wallet from saved connection data
|
||||
* Used on app startup to reconnect to a previously connected wallet
|
||||
* Validates the connection using the support$ observable
|
||||
*/
|
||||
export function restoreWallet(connection: NWCConnection): WalletConnect {
|
||||
export async function restoreWallet(
|
||||
connection: NWCConnection,
|
||||
): Promise<WalletConnect> {
|
||||
connectionStatus$.next("connecting");
|
||||
lastError$.next(null);
|
||||
|
||||
walletInstance = new WalletConnect({
|
||||
service: connection.service,
|
||||
relays: connection.relays,
|
||||
secret: hexToBytes(connection.secret),
|
||||
});
|
||||
|
||||
// Set initial balance from cache
|
||||
// Set initial balance from cache while we validate
|
||||
if (connection.balance !== undefined) {
|
||||
balance$.next(connection.balance);
|
||||
}
|
||||
|
||||
subscribeToNotifications(walletInstance);
|
||||
// Subscribe to support$ for cached wallet capabilities
|
||||
subscribeToSupport(walletInstance);
|
||||
|
||||
// Validate connection by waiting for support info with timeout
|
||||
try {
|
||||
console.log("[NWC] Validating wallet connection...");
|
||||
await firstValueFrom(
|
||||
walletInstance.support$.pipe(
|
||||
timeout({
|
||||
first: 10000, // 10 second timeout for first value
|
||||
with: () => {
|
||||
throw new Error("Connection validation timeout");
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
console.log("[NWC] Wallet connection validated");
|
||||
connectionStatus$.next("connected");
|
||||
} catch (error) {
|
||||
console.error("[NWC] Wallet validation failed:", error);
|
||||
connectionStatus$.next("error");
|
||||
lastError$.next(
|
||||
error instanceof Error
|
||||
? error
|
||||
: new Error("Connection validation failed"),
|
||||
);
|
||||
// Continue anyway - notifications subscription will retry
|
||||
}
|
||||
|
||||
// Subscribe to notifications with retry logic
|
||||
subscribeToNotificationsWithRetry(walletInstance);
|
||||
|
||||
// Refresh balance from wallet (not just cache)
|
||||
refreshBalance();
|
||||
|
||||
return walletInstance;
|
||||
}
|
||||
|
||||
@@ -126,25 +262,80 @@ export function clearWallet(): void {
|
||||
notificationSubscription.unsubscribe();
|
||||
notificationSubscription = null;
|
||||
}
|
||||
if (supportSubscription) {
|
||||
supportSubscription.unsubscribe();
|
||||
supportSubscription = null;
|
||||
}
|
||||
walletInstance = null;
|
||||
balance$.next(undefined);
|
||||
connectionStatus$.next("disconnected");
|
||||
lastError$.next(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually refresh the balance from the wallet
|
||||
* Useful for initial load or manual refresh button
|
||||
* Includes retry logic for reliability
|
||||
*/
|
||||
export async function refreshBalance(): Promise<number | undefined> {
|
||||
if (!walletInstance) return undefined;
|
||||
|
||||
try {
|
||||
const result = await walletInstance.getBalance();
|
||||
const newBalance = result.balance;
|
||||
const maxRetries = 3;
|
||||
const baseDelay = 1000;
|
||||
|
||||
balance$.next(newBalance);
|
||||
return newBalance;
|
||||
} catch (error) {
|
||||
console.error("[NWC] Failed to refresh balance:", error);
|
||||
return undefined;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await walletInstance.getBalance();
|
||||
const newBalance = result.balance;
|
||||
|
||||
balance$.next(newBalance);
|
||||
|
||||
// Mark as connected on successful balance fetch
|
||||
if (connectionStatus$.value === "error") {
|
||||
connectionStatus$.next("connected");
|
||||
lastError$.next(null);
|
||||
}
|
||||
|
||||
return newBalance;
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[NWC] Failed to refresh balance (attempt ${attempt + 1}):`,
|
||||
error,
|
||||
);
|
||||
|
||||
if (attempt < maxRetries - 1) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, baseDelay * Math.pow(2, attempt)),
|
||||
);
|
||||
} else {
|
||||
// Only set error state on final failure if not already connected
|
||||
if (connectionStatus$.value !== "connected") {
|
||||
connectionStatus$.next("error");
|
||||
lastError$.next(
|
||||
error instanceof Error ? error : new Error("Failed to get balance"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconnect the wallet
|
||||
* Call this when the user wants to manually retry after an error
|
||||
*/
|
||||
export async function reconnect(): Promise<void> {
|
||||
if (!walletInstance) return;
|
||||
|
||||
connectionStatus$.next("connecting");
|
||||
lastError$.next(null);
|
||||
|
||||
// Re-subscribe to support and notifications
|
||||
subscribeToSupport(walletInstance);
|
||||
subscribeToNotificationsWithRetry(walletInstance);
|
||||
|
||||
// Try to refresh balance
|
||||
await refreshBalance();
|
||||
}
|
||||
|
||||
@@ -100,6 +100,7 @@ export interface NWCConnection {
|
||||
/** Optional wallet info */
|
||||
info?: {
|
||||
alias?: string;
|
||||
network?: string;
|
||||
methods?: string[];
|
||||
notifications?: string[];
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user