From a1cca4a748208977a8bba673cbd0206d6c77df24 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 21 May 2026 20:05:02 +0000 Subject: [PATCH] fix(nwc): flush REQ before publishing event and surface silent errors Two problems caused wallet timeouts that affected only Amethyst users: 1. The NWC subscription went through a 500 ms BundledUpdate debounce while the request event was published immediately. Kind 23195 responses are ephemeral, so if the wallet replied faster than the REQ reached the relay, the reply was dropped with no replay. Add a synchronous subscribeAndFlush() that bypasses the bundler so the REQ is queued on the WebSocket before the EVENT. 2. Three failure paths were swallowed: decryption returning null, an unknown response subtype, and a response arriving with no matching pending request. Users saw "Wallet request timed out" with no clue. Surface a specific error in WalletViewModel for the first two, and log a warning in LocalCache for the third. --- .../amethyst/model/LocalCache.kt | 10 +++++- .../nip47WalletConnect/NwcSignerState.kt | 10 ++++-- .../nwc/NWCPaymentFilterAssembler.kt | 12 +++++++ .../screen/loggedIn/wallet/WalletViewModel.kt | 32 ++++++++++++++++--- 4 files changed, 57 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 2d9671bf6d..12d34ba2d6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -2099,7 +2099,15 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ): Boolean { val requestId = event.requestId() - val pending = paymentTracker.onResponseReceived(requestId) ?: return false + val pending = + paymentTracker.onResponseReceived(requestId) ?: run { + Log.w( + "LocalCache", + "NWC response ${event.id} from ${event.pubKey} references request e=$requestId but no pending request is registered. " + + "The response was either delivered after timeout, the user holds a stale subscription, or the wallet service set the wrong e tag.", + ) + return false + } val zappedNote = pending.zappedNote val responseCallback = pending.onResponse diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index 5bf93e5ff2..1877c30d04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -152,7 +152,11 @@ class NwcSignerState( val assembler = nwcFilterAssembler() - assembler.subscribe(filter) + // Synchronous flush so the REQ frame is queued on the WebSocket before + // the EVENT is published. Without this, the bundler may delay REQ up + // to 500ms, and the wallet service's ephemeral kind 23195 reply can + // be missed. + assembler.subscribeAndFlush(filter) scope.launch(Dispatchers.IO) { delay(60000) @@ -189,7 +193,9 @@ class NwcSignerState( val assembler = nwcFilterAssembler() - assembler.subscribe(filter) + // Synchronous flush so the REQ frame is queued before the EVENT. + // See sendNwcRequestToWallet above for the rationale. + assembler.subscribeAndFlush(filter) scope.launch(Dispatchers.IO) { delay(60000) // waits 1 minute to complete payment. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt index 48c66be4ee..46df66e2d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt @@ -48,5 +48,17 @@ class NWCPaymentFilterAssembler( override fun invalidateKeys() = invalidateFilters() + /** + * Synchronously sends the REQ frame to the relay, bypassing the 500ms + * BundledUpdate debounce. Used for NIP-47 RPC where the response is an + * ephemeral event (kind 23195) and the subscription must be active on the + * relay before we publish the request event — otherwise the relay drops + * the response with no replay. + */ + fun subscribeAndFlush(query: NWCPaymentQueryState) { + subscribe(query) + group.forEach { it.forceInvalidate() } + } + override fun destroy() = group.forEach { it.destroy() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index f05e5c0a68..34f9bc673c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -172,6 +173,19 @@ class WalletViewModel : ViewModel() { private val _receiveState = MutableStateFlow(ReceiveState.Idle) val receiveState = _receiveState.asStateFlow() + // A null `Response` means the wallet service replied but the payload could + // not be decrypted (wrong key, unexpected format). Any other unexpected + // subtype means the response shape didn't match any known NIP-47 result. + // Both used to be swallowed silently — now we surface them so the user + // can distinguish "wallet never answered" from "wallet answered with + // something we can't read". + private fun unreadableResponseError(response: Response?): String = + if (response == null) { + "Could not decrypt the wallet's reply — the wallet may be using a different key" + } else { + "Wallet returned an unrecognized reply for ${response.resultType}" + } + private fun launchTimeout(onTimeout: () -> Unit): Job = viewModelScope.launch(Dispatchers.IO) { delay(NWC_TIMEOUT_MS) @@ -315,7 +329,9 @@ class WalletViewModel : ViewModel() { } else -> { - updateWalletInfo(walletId) { it.copy(isLoading = false) } + updateWalletInfo(walletId) { + it.copy(error = unreadableResponseError(response), isLoading = false) + } } } } @@ -376,13 +392,16 @@ class WalletViewModel : ViewModel() { is GetBalanceSuccessResponse -> { _balanceSats.value = (response.result?.balance ?: 0L) / 1000L updateWalletInfo(walletId) { it.copy(balanceSats = _balanceSats.value) } + _error.value = null } is NwcErrorResponse -> { _error.value = response.error?.message ?: "Balance request failed" } - else -> {} + else -> { + _error.value = unreadableResponseError(response) + } } _isLoading.value = false } @@ -444,13 +463,16 @@ class WalletViewModel : ViewModel() { } else { txs.size >= pageSize } + _error.value = null } is NwcErrorResponse -> { _error.value = response.error?.message ?: "Failed to load transactions" } - else -> {} + else -> { + _error.value = unreadableResponseError(response) + } } _isLoading.value = false } @@ -498,7 +520,9 @@ class WalletViewModel : ViewModel() { _error.value = response.error?.message ?: "Failed to load more transactions" } - else -> {} + else -> { + _error.value = unreadableResponseError(response) + } } _isLoadingMore.value = false }