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
This commit is contained in:
Claude
2026-01-30 09:59:22 +00:00
parent 121fbb7654
commit 176f1ff797
2 changed files with 135 additions and 33 deletions

View File

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

View File

@@ -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<typeof setTimeout> | 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<void> {
// 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<WalletConnect> {
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<WalletConnect> {
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<number | undefined> {
/**
* Attempts to reconnect after an error.
* Resets support tracking and waits for wallet to become ready again.
*/
export async function reconnect(): Promise<void> {
const wallet = wallet$.value;
@@ -289,9 +366,23 @@ export async function reconnect(): Promise<void> {
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"),
);
}
}
// ============================================================================