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 }