From 176f1ff79753c34734964f163dbd4404931faeed Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Jan 2026 09:59:22 +0000 Subject: [PATCH] fix: prevent NWC pay_invoice from hanging indefinitely The applesauce-wallet-connect library's genericCall method waits for encryption$ (derived from support$) without any timeout. If the wallet service doesn't send kind 13194 (wallet info) events quickly, all wallet operations hang forever. Changes: - Add waitForSupport() function with 15s timeout to prime support$ - Add ensureWalletReady() export that must be called before operations - Reorder restoreWallet() to set up notifications FIRST (keeps events$ alive), then wait for support info - Update all wallet methods in useWallet hook to call ensureWalletReady() before operations, ensuring proper timeout handling - Update reconnect() to properly reset and retry support info This ensures wallet operations fail fast with a clear error message instead of hanging indefinitely. https://claude.ai/code/session_018fU3rYmjFPEKz3ot1itLZL --- src/hooks/useWallet.ts | 39 ++++++++----- src/services/nwc.ts | 129 +++++++++++++++++++++++++++++++++++------ 2 files changed, 135 insertions(+), 33 deletions(-) diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 0f5fe99..41ceb24 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -40,6 +40,7 @@ import { loadTransactions as loadTransactionsService, loadMoreTransactions as loadMoreTransactionsService, retryLoadTransactions as retryLoadTransactionsService, + ensureWalletReady, } from "@/services/nwc"; export function useWallet() { @@ -90,9 +91,19 @@ export function useWallet() { // Wallet operations // ============================================================================ + /** + * Pay a Lightning invoice via NWC. + * + * IMPORTANT: This first calls ensureWalletReady() to ensure the wallet's + * support$ observable has emitted. Without this, wallet.payInvoice() can + * hang forever because the applesauce-wallet-connect library's genericCall + * waits for encryption$ (derived from support$) without any timeout. + */ async function payInvoice(invoice: string, amount?: number) { - if (!wallet) throw new Error("No wallet connected"); - const result = await wallet.payInvoice(invoice, amount); + // Ensure wallet is ready - this waits for support$ with a timeout + // instead of letting it hang forever + const readyWallet = await ensureWalletReady(); + const result = await readyWallet.payInvoice(invoice, amount); await refreshBalanceService(); return result; } @@ -105,18 +116,18 @@ export function useWallet() { expiry?: number; }, ) { - if (!wallet) throw new Error("No wallet connected"); - return await wallet.makeInvoice(amount, options); + const readyWallet = await ensureWalletReady(); + return await readyWallet.makeInvoice(amount, options); } async function getInfo() { - if (!wallet) throw new Error("No wallet connected"); - return await wallet.getInfo(); + const readyWallet = await ensureWalletReady(); + return await readyWallet.getInfo(); } async function getBalance() { - if (!wallet) throw new Error("No wallet connected"); - const result = await wallet.getBalance(); + const readyWallet = await ensureWalletReady(); + const result = await readyWallet.getBalance(); return result.balance; } @@ -128,18 +139,18 @@ export function useWallet() { unpaid?: boolean; type?: "incoming" | "outgoing"; }) { - if (!wallet) throw new Error("No wallet connected"); - return await wallet.listTransactions(options); + const readyWallet = await ensureWalletReady(); + return await readyWallet.listTransactions(options); } async function lookupInvoice(paymentHash: string) { - if (!wallet) throw new Error("No wallet connected"); - return await wallet.lookupInvoice(paymentHash); + const readyWallet = await ensureWalletReady(); + return await readyWallet.lookupInvoice(paymentHash); } 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); + const readyWallet = await ensureWalletReady(); + const result = await readyWallet.payKeysend(pubkey, amount, preimage); await refreshBalanceService(); return result; } diff --git a/src/services/nwc.ts b/src/services/nwc.ts index 386a552..06e2a5d 100644 --- a/src/services/nwc.ts +++ b/src/services/nwc.ts @@ -18,7 +18,14 @@ import { INITIAL_TRANSACTIONS_STATE, } from "@/types/wallet"; import pool from "./relay-pool"; -import { BehaviorSubject, Subscription, firstValueFrom, timeout } from "rxjs"; +import { + BehaviorSubject, + Subscription, + firstValueFrom, + race, + timer, + map, +} from "rxjs"; // Configure the pool for wallet connect WalletConnect.pool = pool; @@ -27,6 +34,14 @@ WalletConnect.pool = pool; let notificationSubscription: Subscription | null = null; let notificationRetryTimeout: ReturnType | null = null; +/** + * Tracks whether the wallet's support$ observable has emitted. + * The applesauce-wallet-connect library's genericCall waits for encryption$ + * which derives from support$. If support$ never emits, all wallet methods hang forever. + * We use this flag to track readiness and ensure support$ has emitted before operations. + */ +let walletSupportReceived = false; + /** * Connection status for the NWC wallet */ @@ -138,6 +153,68 @@ function subscribeToNotifications(wallet: WalletConnect) { subscribe(); } +/** + * Waits for the wallet's support$ to emit, ensuring the wallet is ready for operations. + * + * CRITICAL: The applesauce-wallet-connect library's genericCall method waits for + * encryption$ (derived from support$) without any timeout. If support$ never emits + * (e.g., wallet service doesn't send kind 13194 events quickly), operations hang forever. + * + * This function must be called before any wallet operation to ensure support$ has emitted. + * It will wait up to the specified timeout before throwing an error. + * + * @param wallet The wallet instance + * @param timeoutMs Maximum time to wait (default: 15 seconds) + * @throws Error if support$ doesn't emit within timeout + */ +async function waitForSupport( + wallet: WalletConnect, + timeoutMs = 15000, +): Promise { + // Already received support info + if (walletSupportReceived) { + return; + } + + try { + // Race support$ against a timeout + // This ensures we don't hang forever waiting for kind 13194 events + await firstValueFrom( + race( + wallet.support$, + timer(timeoutMs).pipe( + map(() => { + throw new Error( + "Wallet connection timeout - wallet service not responding", + ); + }), + ), + ), + ); + walletSupportReceived = true; + console.log("[NWC] Wallet support info received, ready for operations"); + } catch (error) { + console.error("[NWC] Failed to get wallet support info:", error); + throw error; + } +} + +/** + * Ensures the wallet is ready before performing operations. + * This must be called before any wallet method that uses genericCall internally. + * + * @throws Error if wallet not connected or not ready + */ +export async function ensureWalletReady(): Promise { + const wallet = wallet$.value; + if (!wallet) { + throw new Error("No wallet connected"); + } + + await waitForSupport(wallet); + return wallet; +} + // ============================================================================ // Public API // ============================================================================ @@ -161,13 +238,15 @@ export function createWalletFromURI(connectionString: string): WalletConnect { /** * Restores a wallet from saved connection data. - * Validates the connection before marking as connected. + * Sets up notification subscription first to keep events$ alive, + * then waits for support info to validate the connection. */ export async function restoreWallet( connection: NWCConnection, ): Promise { connectionStatus$.next("connecting"); lastError$.next(null); + walletSupportReceived = false; const wallet = new WalletConnect({ service: connection.service, @@ -182,31 +261,27 @@ export async function restoreWallet( balance$.next(connection.balance); } - // Validate connection by waiting for support info + // IMPORTANT: Subscribe to notifications FIRST to keep events$ alive. + // This ensures the relay subscription is established before we wait for support$. + // Without this, support$ might never emit because there's no active subscription. + subscribeToNotifications(wallet); + + // Now wait for support$ to emit (validates the wallet is responding) + // This uses a longer timeout since we need the relay to connect and wallet to respond try { - await firstValueFrom( - wallet.support$.pipe( - timeout({ - first: 10000, - with: () => { - throw new Error("Connection timeout"); - }, - }), - ), - ); + await waitForSupport(wallet, 15000); connectionStatus$.next("connected"); + // Start fetching balance after wallet is ready + refreshBalance(); } catch (error) { - console.error("[NWC] Validation failed:", error); + console.error("[NWC] Wallet validation failed:", error); connectionStatus$.next("error"); lastError$.next( error instanceof Error ? error : new Error("Connection failed"), ); - // Continue anyway - notifications will retry + // Don't call refreshBalance() here - the wallet isn't ready and it would hang } - subscribeToNotifications(wallet); - refreshBalance(); - return wallet; } @@ -226,6 +301,7 @@ export function clearWallet(): void { balance$.next(undefined); connectionStatus$.next("disconnected"); lastError$.next(null); + walletSupportReceived = false; resetTransactions(); } @@ -282,6 +358,7 @@ export async function refreshBalance(): Promise { /** * Attempts to reconnect after an error. + * Resets support tracking and waits for wallet to become ready again. */ export async function reconnect(): Promise { const wallet = wallet$.value; @@ -289,9 +366,23 @@ export async function reconnect(): Promise { connectionStatus$.next("connecting"); lastError$.next(null); + walletSupportReceived = false; + // Re-subscribe to notifications (this keeps events$ alive) subscribeToNotifications(wallet); - await refreshBalance(); + + // Wait for wallet to become ready + try { + await waitForSupport(wallet, 15000); + connectionStatus$.next("connected"); + await refreshBalance(); + } catch (error) { + console.error("[NWC] Reconnect failed:", error); + connectionStatus$.next("error"); + lastError$.next( + error instanceof Error ? error : new Error("Reconnection failed"), + ); + } } // ============================================================================