Merge pull request #3032 from vitorpamplona/claude/jolly-cray-6vKga

Surface NIP-47 wallet response errors to user
This commit is contained in:
Vitor Pamplona
2026-05-21 16:45:11 -04:00
committed by GitHub
4 changed files with 57 additions and 7 deletions
@@ -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
@@ -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.
@@ -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() }
}
@@ -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>(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
}